mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 06:34:44 -05:00
feat: Enhance plugin store and experiment design infrastructure
- Add plugin store system with dynamic loading of robot actions - Implement plugin store API routes and database schema - Update experiment designer to support plugin-based actions - Refactor environment configuration and sidebar navigation - Improve authentication session handling with additional user details - Update Tailwind CSS configuration and global styles - Remove deprecated files and consolidate project structure
This commit is contained in:
19
.vscode/settings.json
vendored
19
.vscode/settings.json
vendored
@@ -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
|
||||
}
|
||||
5
bun.lock
5
bun.lock
@@ -12,6 +12,7 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^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=="],
|
||||
|
||||
"@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.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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="],
|
||||
|
||||
55
docs/DESIGN_DECISIONS.md
Normal file
55
docs/DESIGN_DECISIONS.md
Normal 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
29
docs/README.md
Normal 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
249
docs/architecture.md
Normal 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.
|
||||
365
docs/auth-and-permissions.md
Normal file
365
docs/auth-and-permissions.md
Normal 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
304
docs/data-layer.md
Normal 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
376
docs/development.md
Normal 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
383
docs/experiment-designer.md
Normal 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
306
docs/future-roadmap.md
Normal 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
119
docs/plan.md
Normal 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
|
||||
@@ -1,403 +1,371 @@
|
||||
# Robot Plugin Store Architecture
|
||||
|
||||
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.
|
||||
# Plugin Store System
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin store consists of:
|
||||
- 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
|
||||
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.
|
||||
|
||||
## 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
|
||||
interface RobotPlugin {
|
||||
// Core metadata
|
||||
robotId: string; // Unique identifier for this robot
|
||||
name: string; // Display name
|
||||
description?: string; // Optional description
|
||||
platform: string; // e.g., "ROS2", "custom"
|
||||
version: string; // Semver version number
|
||||
|
||||
// Manufacturer information
|
||||
robotId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
platform: string;
|
||||
version: string;
|
||||
manufacturer: {
|
||||
name: string; // Manufacturer name
|
||||
website: string; // Manufacturer website
|
||||
support?: string; // Support URL
|
||||
name: string;
|
||||
website?: string;
|
||||
support?: string;
|
||||
};
|
||||
|
||||
// Documentation
|
||||
documentation: {
|
||||
mainUrl: string; // Main documentation URL
|
||||
apiReference?: string; // API/ROS2 interface documentation
|
||||
wikiUrl?: string; // Wiki or community documentation
|
||||
videoUrl?: string; // Video tutorial or overview
|
||||
mainUrl: string;
|
||||
apiReference?: string;
|
||||
wikiUrl?: string;
|
||||
videoUrl?: string;
|
||||
};
|
||||
|
||||
// Visual assets
|
||||
assets: {
|
||||
thumbnailUrl: string; // Small preview image
|
||||
images: { // Various robot images
|
||||
main: string; // Main robot image
|
||||
angles?: { // Optional different view angles
|
||||
thumbnailUrl: string;
|
||||
images: {
|
||||
main: string;
|
||||
angles?: {
|
||||
front?: string;
|
||||
side?: string;
|
||||
top?: string;
|
||||
};
|
||||
dimensions?: string; // Technical drawing with dimensions
|
||||
dimensions?: string;
|
||||
};
|
||||
model?: { // 3D model information
|
||||
format: "URDF" | "glTF" | "other";
|
||||
model?: {
|
||||
format: string;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Technical specifications
|
||||
specs: {
|
||||
dimensions: {
|
||||
length: number; // in meters
|
||||
length: number;
|
||||
width: number;
|
||||
height: number;
|
||||
weight: number; // in kg
|
||||
weight: number;
|
||||
};
|
||||
capabilities: string[]; // e.g., ["differential_drive", "lidar", "camera"]
|
||||
maxSpeed: number; // in m/s
|
||||
batteryLife: number; // in hours
|
||||
payload?: number; // max payload in kg
|
||||
capabilities: string[];
|
||||
maxSpeed: number;
|
||||
batteryLife: number;
|
||||
};
|
||||
|
||||
// Available actions for this robot
|
||||
actions: ActionDefinition[];
|
||||
}
|
||||
```
|
||||
|
||||
// Platform-specific configuration
|
||||
ros2Config: {
|
||||
namespace: string;
|
||||
nodePrefix: string;
|
||||
defaultTopics: {
|
||||
cmd_vel: string;
|
||||
odom: string;
|
||||
scan: string;
|
||||
[key: string]: string;
|
||||
## Repository Management
|
||||
|
||||
### Loading Repositories
|
||||
|
||||
```typescript
|
||||
async loadRepository(url: string): Promise<RepositoryMetadata> {
|
||||
// Clean URL
|
||||
const cleanUrl = url.trim().replace(/\/$/, "");
|
||||
|
||||
try {
|
||||
// Fetch repository metadata
|
||||
const metadataUrl = this.getRepositoryFileUrl(cleanUrl, "repository.json");
|
||||
const response = await fetch(metadataUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch repository metadata: ${response.statusText}`);
|
||||
}
|
||||
|
||||
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 {
|
||||
actionId: string; // Unique identifier for this action
|
||||
type: ActionType; // Type of action (move, speak, etc.)
|
||||
title: string; // Display name
|
||||
description: string; // Description of what the action does
|
||||
icon?: string; // Icon identifier for the UI
|
||||
|
||||
// Parameter definition (using JSON Schema)
|
||||
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; // e.g., "m/s", "rad", "m"
|
||||
}>;
|
||||
properties: Record<string, ParameterProperty>;
|
||||
required: string[];
|
||||
};
|
||||
|
||||
// ROS2 Integration details
|
||||
ros2: {
|
||||
messageType: string; // ROS message type
|
||||
topic?: string; // ROS topic to publish to
|
||||
service?: string; // ROS service to call
|
||||
action?: string; // ROS action to execute
|
||||
payloadMapping: { // How parameters map to ROS messages
|
||||
ros2?: {
|
||||
messageType: string;
|
||||
topic?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
payloadMapping: {
|
||||
type: "direct" | "transform";
|
||||
map?: Record<string, 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;
|
||||
transformFn?: string;
|
||||
};
|
||||
qos?: QoSSettings;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Example Plugin JSON
|
||||
### Transform Functions
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
```typescript
|
||||
private transformToTwist(params: { linear: number; angular: number }) {
|
||||
return {
|
||||
linear: {
|
||||
x: params.linear,
|
||||
y: 0.0,
|
||||
z: 0.0
|
||||
},
|
||||
"model": {
|
||||
"format": "URDF",
|
||||
"url": "https://raw.githubusercontent.com/ROBOTIS-GIT/turtlebot3/master/turtlebot3_description/urdf/turtlebot3_burger.urdf"
|
||||
angular: {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: params.angular
|
||||
}
|
||||
},
|
||||
|
||||
"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
|
||||
## Caching & Performance
|
||||
|
||||
### 1. Plugin Store Module
|
||||
|
||||
Create a TypeScript module to manage plugins:
|
||||
### Cache Implementation
|
||||
|
||||
```typescript
|
||||
// src/lib/plugin-store/types.ts
|
||||
export interface RobotPlugin { ... }
|
||||
export interface ActionDefinition { ... }
|
||||
private shouldRefreshCache(repositoryId: string): boolean {
|
||||
const lastRefresh = this.lastRefresh.get(repositoryId);
|
||||
if (!lastRefresh) return true;
|
||||
|
||||
// src/lib/plugin-store/store.ts
|
||||
export class PluginStore {
|
||||
private plugins: Map<string, RobotPlugin>;
|
||||
|
||||
async loadPlugins(): Promise<void>;
|
||||
async getPlugin(robotId: string): Promise<RobotPlugin>;
|
||||
async getAllPlugins(): Promise<RobotPlugin[]>;
|
||||
const now = Date.now();
|
||||
return now - lastRefresh > this.CACHE_TTL;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Admin Interface
|
||||
|
||||
Build an admin panel for managing plugins:
|
||||
- Upload/edit JSON plugin definitions
|
||||
- Validate plugin schema
|
||||
- Version management
|
||||
- Preview plugin details
|
||||
|
||||
### 3. API Routes
|
||||
|
||||
Create API endpoints for plugin management:
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
// GET /api/plugins
|
||||
// GET /api/plugins/:robotId
|
||||
// POST /api/plugins (with auth)
|
||||
// PUT /api/plugins/:robotId (with auth)
|
||||
// DELETE /api/plugins/:robotId (with auth)
|
||||
```
|
||||
|
||||
### 4. Experiment Designer Integration
|
||||
|
||||
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
|
||||
// src/lib/ros2-bridge/bridge.ts
|
||||
export class ROS2Bridge {
|
||||
async executeAction(
|
||||
robotId: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<void>;
|
||||
export class PluginLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public robotId?: string,
|
||||
public cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PluginLoadError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development Phases
|
||||
## Usage Examples
|
||||
|
||||
1. **Phase 1: Core Plugin Store**
|
||||
- Implement plugin schema and validation
|
||||
- Build plugin loader
|
||||
- Create basic API endpoints
|
||||
### Loading a Repository
|
||||
|
||||
2. **Phase 2: Admin Interface**
|
||||
- Build plugin management UI
|
||||
- Implement plugin upload/edit
|
||||
- Add version control
|
||||
```typescript
|
||||
const store = new PluginStore();
|
||||
await store.loadRepository("https://github.com/org/robot-plugins");
|
||||
```
|
||||
|
||||
3. **Phase 3: Experiment Designer Integration**
|
||||
- Add robot selection
|
||||
- Update action library based on selection
|
||||
- Enhance action configuration
|
||||
### Getting Plugin Information
|
||||
|
||||
4. **Phase 4: ROS2 Bridge**
|
||||
- Implement ROS2 connection
|
||||
- Add message transformation
|
||||
- Test with real robots
|
||||
```typescript
|
||||
const plugin = store.getPlugin("turtlebot3-burger");
|
||||
if (plugin) {
|
||||
console.log(`Loaded ${plugin.name} version ${plugin.version}`);
|
||||
console.log(`Supported actions: ${plugin.actions.length}`);
|
||||
}
|
||||
```
|
||||
|
||||
5. **Phase 5: Documentation & Testing**
|
||||
- Write contributor guidelines
|
||||
- Add comprehensive tests
|
||||
- Create example plugins
|
||||
### Registering Transform Functions
|
||||
|
||||
## Contributing
|
||||
```typescript
|
||||
store.registerTransformFunction("transformToTwist", (params) => {
|
||||
// Custom transformation logic
|
||||
return transformedData;
|
||||
});
|
||||
```
|
||||
|
||||
To add a new robot to the plugin store:
|
||||
## Best Practices
|
||||
|
||||
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
|
||||
1. **Error Handling:**
|
||||
- Always catch and properly handle plugin loading errors
|
||||
- Provide meaningful error messages
|
||||
- Include error context when possible
|
||||
|
||||
2. **Validation:**
|
||||
- Validate all plugin metadata
|
||||
- Verify action parameters
|
||||
- Check compatibility requirements
|
||||
|
||||
3. **Performance:**
|
||||
- Use caching appropriately
|
||||
- Implement lazy loading where possible
|
||||
- Monitor memory usage
|
||||
|
||||
4. **Security:**
|
||||
- Validate URLs and file paths
|
||||
- Implement proper access controls
|
||||
- Sanitize plugin inputs
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Plugin marketplace for sharing robot definitions
|
||||
- Visual plugin builder in admin interface
|
||||
- Real-time plugin updates
|
||||
- Plugin dependency management
|
||||
- Custom action visualization components
|
||||
1. **Plugin Versioning:**
|
||||
- Semantic versioning support
|
||||
- Version compatibility checking
|
||||
- Update management
|
||||
|
||||
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
1
docs/root.tex
Symbolic link
@@ -0,0 +1 @@
|
||||
/Users/soconnor/Projects/csci378/hristudio-sp2025/root.tex
|
||||
346
docs/ui-design.md
Normal file
346
docs/ui-design.md
Normal 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
|
||||
@@ -1,12 +1,19 @@
|
||||
import { type Config } from "drizzle-kit";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { env } from "~/env.mjs";
|
||||
|
||||
export default {
|
||||
schema: "./src/server/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
},
|
||||
strict: false,
|
||||
verbose: true,
|
||||
migrations: {
|
||||
table: "__drizzle_migrations",
|
||||
schema: "public"
|
||||
},
|
||||
tablesFilter: ["hs_*"],
|
||||
} satisfies Config;
|
||||
|
||||
@@ -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
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1739338336977,
|
||||
"tag": "0000_adorable_grandmaster",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
61
next.config.mjs
Normal 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;
|
||||
@@ -9,6 +9,8 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"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",
|
||||
"dev": "next dev --turbo",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
|
||||
223
public/root.tex
223
public/root.tex
@@ -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}
|
||||
88
src/app/api/plugins/install/route.ts
Normal file
88
src/app/api/plugins/install/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPlugin } from "~/lib/plugin-store/service";
|
||||
import { db } from "~/server/db";
|
||||
import { installedPlugins } from "~/server/db/schema/store";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// POST /api/plugins/install - Install a plugin
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const schema = z.object({
|
||||
robotId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
});
|
||||
|
||||
const { robotId, repositoryId } = schema.parse(body);
|
||||
|
||||
// Get plugin details
|
||||
const plugin = await getPlugin(robotId);
|
||||
if (!plugin) {
|
||||
return NextResponse.json(
|
||||
{ error: "Plugin not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
const existing = await db.query.installedPlugins.findFirst({
|
||||
where: eq(installedPlugins.robotId, robotId),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Plugin already installed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Install plugin
|
||||
const installed = await db.insert(installedPlugins).values({
|
||||
robotId,
|
||||
repositoryId,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
enabled: true,
|
||||
config: {},
|
||||
}).returning();
|
||||
|
||||
return NextResponse.json(installed[0]);
|
||||
} catch (error) {
|
||||
console.error("Failed to install plugin:", error);
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to install plugin" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/plugins/install - Uninstall a plugin
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const robotId = url.searchParams.get("robotId");
|
||||
|
||||
if (!robotId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Robot ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(installedPlugins).where(eq(installedPlugins.robotId, robotId));
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to uninstall plugin:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to uninstall plugin" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/dashboard/experiments/[id]/designer/page.tsx
Normal file
41
src/app/dashboard/experiments/[id]/designer/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export default function ExperimentDesignerPage() {
|
||||
const { state, setOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<PageHeader className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Experiment Designer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Design your experiment workflow by dragging and connecting actions
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setOpen(state === "expanded" ? false : true)}
|
||||
>
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<PageContent className={cn(
|
||||
"flex-1 overflow-hidden p-0",
|
||||
// Adjust margin when app sidebar is collapsed
|
||||
state === "collapsed" && "ml-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]",
|
||||
state === "expanded" && "ml-[calc(var(--sidebar-width))]"
|
||||
)}>
|
||||
<ExperimentDesigner />
|
||||
</PageContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AppSidebar } from "~/components/navigation/app-sidebar"
|
||||
import { Header } from "~/components/navigation/header"
|
||||
import { SidebarProvider } from "~/components/ui/sidebar"
|
||||
import { StudyProvider } from "~/components/providers/study-provider"
|
||||
import { PluginStoreProvider } from "~/components/providers/plugin-store-provider"
|
||||
import { PageTransition } from "~/components/layout/page-transition"
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -34,8 +35,8 @@ export default function DashboardLayout({
|
||||
}, [status, router])
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect if we've loaded studies and user has none
|
||||
if (!isLoadingStudies && studies && studies.length === 0) {
|
||||
// Only redirect if we've loaded studies and user has none, and we're not already on onboarding
|
||||
if (!isLoadingStudies && studies && studies.length === 0 && !window.location.pathname.includes("/onboarding")) {
|
||||
router.replace("/onboarding")
|
||||
}
|
||||
}, [studies, isLoadingStudies, router])
|
||||
@@ -57,19 +58,47 @@ export default function DashboardLayout({
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<StudyProvider>
|
||||
<div className="flex h-full min-h-screen w-full">
|
||||
<AppSidebar />
|
||||
<div className="flex w-0 flex-1 flex-col">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto p-4">
|
||||
<PageTransition>
|
||||
{children}
|
||||
</PageTransition>
|
||||
</main>
|
||||
<PluginStoreProvider>
|
||||
<StudyProvider>
|
||||
<div className="flex h-full min-h-screen w-full bg-muted/40 dark:bg-background relative">
|
||||
{/* Background Elements */}
|
||||
<div className="pointer-events-none fixed inset-0 z-0">
|
||||
{/* Base Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-primary/10 to-background" />
|
||||
|
||||
{/* Gradient Orb */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="absolute h-[1200px] w-[1200px] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-primary/30 via-secondary/30 to-background opacity-60 blur-3xl dark:opacity-40 animate-gradient" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dotted Pattern */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.35] dark:opacity-[0.15]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(circle at 1px 1px, hsl(var(--primary)/0.5) 1px, transparent 0)
|
||||
`,
|
||||
backgroundSize: '32px 32px',
|
||||
maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<AppSidebar className="z-20" />
|
||||
<div className="flex w-0 flex-1 flex-col z-10">
|
||||
<Header/>
|
||||
<main className="flex-1 overflow-auto p-4">
|
||||
<PageTransition>
|
||||
{children}
|
||||
</PageTransition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StudyProvider>
|
||||
</StudyProvider>
|
||||
</PluginStoreProvider>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
32
src/app/dashboard/store/page.tsx
Normal file
32
src/app/dashboard/store/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { api } from "~/trpc/server";
|
||||
import { PluginBrowser } from "~/components/store/plugin-browser";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { AddRepositoryDialog } from "~/components/store/add-repository-dialog";
|
||||
import { getCaller } from "~/trpc/server";
|
||||
|
||||
export default async function StorePage() {
|
||||
// Fetch both plugins and repositories using tRPC
|
||||
const caller = await getCaller();
|
||||
const [plugins, repositories] = await Promise.all([
|
||||
caller.pluginStore.getPlugins(),
|
||||
caller.pluginStore.getRepositories(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Robot Store"
|
||||
description="Browse and manage robot plugins"
|
||||
>
|
||||
<AddRepositoryDialog />
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<PluginBrowser
|
||||
repositories={repositories}
|
||||
initialPlugins={plugins}
|
||||
/>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,91 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 200 30% 97%;
|
||||
--foreground: 200 50% 20%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 200 50% 20%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 200 50% 20%;
|
||||
--primary: 200 85% 45%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 200 30% 96%;
|
||||
--secondary-foreground: 200 50% 20%;
|
||||
--muted: 200 30% 96%;
|
||||
--muted-foreground: 200 30% 40%;
|
||||
--accent: 200 85% 45%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 200 30% 90%;
|
||||
--input: 200 30% 90%;
|
||||
--ring: 200 85% 45%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 200 85% 45%;
|
||||
--chart-2: 142 71% 45%;
|
||||
--chart-3: 217 91% 60%;
|
||||
--chart-4: 47 95% 57%;
|
||||
--chart-5: 0 84% 60%;
|
||||
|
||||
/* Sidebar specific colors */
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 200 50% 20%;
|
||||
--sidebar-muted: 200 30% 40%;
|
||||
--sidebar-muted-foreground: 200 30% 40%;
|
||||
--sidebar-accent: 200 30% 96%;
|
||||
--sidebar-accent-foreground: 200 50% 20%;
|
||||
--sidebar-border: 200 30% 90%;
|
||||
--sidebar-ring: 200 85% 45%;
|
||||
--sidebar-hover: 200 40% 96%;
|
||||
--sidebar-active: var(--primary);
|
||||
--sidebar-active-foreground: var(--primary-foreground);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: 200 30% 8%;
|
||||
--foreground: 200 20% 96%;
|
||||
--card: 200 25% 15%;
|
||||
--card-foreground: 200 15% 85%;
|
||||
--popover: 200 50% 8%;
|
||||
--popover-foreground: 200 20% 96%;
|
||||
--primary: 200 70% 40%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 200 30% 20%;
|
||||
--secondary-foreground: 200 20% 96%;
|
||||
--muted: 200 30% 20%;
|
||||
--muted-foreground: 200 30% 65%;
|
||||
--accent: 200 70% 40%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 200 30% 20%;
|
||||
--input: 200 30% 20%;
|
||||
--ring: 200 70% 40%;
|
||||
--chart-1: 200 70% 40%;
|
||||
--chart-2: 142 71% 45%;
|
||||
--chart-3: 217 91% 60%;
|
||||
--chart-4: 47 95% 57%;
|
||||
--chart-5: 0 84% 60%;
|
||||
|
||||
/* Sidebar specific colors - dark mode */
|
||||
--sidebar-background: 200 30% 12%;
|
||||
--sidebar-foreground: 200 20% 96%;
|
||||
--sidebar-muted: 200 30% 65%;
|
||||
--sidebar-muted-foreground: 200 30% 65%;
|
||||
--sidebar-accent: 200 30% 20%;
|
||||
--sidebar-accent-foreground: 200 20% 96%;
|
||||
--sidebar-border: 200 30% 20%;
|
||||
--sidebar-ring: 200 70% 40%;
|
||||
--sidebar-hover: 200 25% 20%;
|
||||
--sidebar-active: var(--primary);
|
||||
--sidebar-active-foreground: var(--primary-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-gradient {
|
||||
@apply relative bg-background;
|
||||
}
|
||||
@@ -19,3 +106,158 @@
|
||||
@apply h-10 bg-background/50 backdrop-blur supports-[backdrop-filter]:bg-background/30;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar and Header shared styles */
|
||||
[data-sidebar="sidebar"],
|
||||
[data-nav="header"] {
|
||||
@apply relative isolate rounded-lg border-[hsl(var(--sidebar-border))] bg-transparent;
|
||||
}
|
||||
|
||||
[data-sidebar="sidebar"]::before,
|
||||
[data-nav="header"]::before {
|
||||
@apply absolute inset-0 -z-10 rounded-lg backdrop-blur-2xl content-[''];
|
||||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
:root [data-sidebar="sidebar"]::before,
|
||||
:root [data-nav="header"]::before {
|
||||
@apply bg-white/50;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root [data-sidebar="sidebar"]::before,
|
||||
:root [data-nav="header"]::before {
|
||||
@apply bg-black/30;
|
||||
}
|
||||
}
|
||||
|
||||
[data-sidebar="sidebar"] {
|
||||
@apply border-r p-2 text-[hsl(var(--sidebar-foreground))];
|
||||
}
|
||||
|
||||
/* Fix collapsed sidebar spacing */
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"] {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="menu"] {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
/* Fix study selector and user bar in collapsed mode */
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="header"],
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"] [data-sidebar="footer"] {
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||
[data-sidebar="header"]
|
||||
[data-sidebar="menu-button"],
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||
[data-sidebar="footer"]
|
||||
[data-sidebar="menu-button"] {
|
||||
@apply !h-8 !w-8 !p-0;
|
||||
}
|
||||
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||
[data-sidebar="header"]
|
||||
[data-sidebar="menu-button"]
|
||||
> div,
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||
[data-sidebar="footer"]
|
||||
[data-sidebar="menu-button"]
|
||||
> div {
|
||||
@apply flex !h-8 !w-8 items-center justify-center !p-0;
|
||||
}
|
||||
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||
[data-sidebar="header"]
|
||||
[data-sidebar="menu-button"]
|
||||
[role="img"],
|
||||
[data-sidebar="sidebar"][data-collapsible="icon"]
|
||||
[data-sidebar="footer"]
|
||||
[data-sidebar="menu-button"]
|
||||
[role="img"] {
|
||||
@apply !h-8 !w-8;
|
||||
}
|
||||
|
||||
/* Regular menu button styles */
|
||||
[data-sidebar="menu-button"] {
|
||||
@apply mt-2 rounded-lg px-2 py-2.5 text-[hsl(var(--sidebar-foreground))] transition-all duration-200 first:mt-0 hover:bg-[hsl(var(--sidebar-hover))] hover:text-[hsl(var(--sidebar-active))];
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"][data-active="true"] {
|
||||
@apply bg-[hsl(var(--sidebar-active))]/10 font-medium text-[hsl(var(--sidebar-active))] ring-1 ring-inset ring-[hsl(var(--sidebar-active))]/20 hover:bg-[hsl(var(--sidebar-active))]/15 hover:ring-[hsl(var(--sidebar-active))]/30;
|
||||
}
|
||||
|
||||
[data-sidebar="group-label"] {
|
||||
@apply text-[hsl(var(--sidebar-muted))];
|
||||
}
|
||||
|
||||
[data-sidebar="menu-action"],
|
||||
[data-sidebar="group-action"] {
|
||||
@apply rounded-lg px-2 py-2.5 text-[hsl(var(--sidebar-muted))] transition-all duration-200 hover:bg-[hsl(var(--sidebar-hover))] hover:text-[hsl(var(--sidebar-active))];
|
||||
}
|
||||
|
||||
/* Card elevation utilities */
|
||||
.card-level-1 {
|
||||
@apply bg-[hsl(var(--card))] shadow-sm transition-shadow duration-200 hover:shadow;
|
||||
}
|
||||
|
||||
.card-level-2 {
|
||||
@apply bg-[hsl(var(--card))] shadow transition-shadow duration-200 hover:shadow-md;
|
||||
}
|
||||
|
||||
.card-level-3 {
|
||||
@apply bg-[hsl(var(--card))] shadow-md transition-shadow duration-200 hover:shadow-lg;
|
||||
}
|
||||
|
||||
/* Gradient Animations */
|
||||
@keyframes gradient-move {
|
||||
0% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.05) rotate(90deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95) rotate(180deg);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.05) rotate(270deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
animation: gradient-move 30s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.step {
|
||||
counter-increment: step;
|
||||
}
|
||||
|
||||
.step:before {
|
||||
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full bg-muted text-center -indent-px font-mono text-base font-medium;
|
||||
@apply ml-[-50px] mt-[-4px];
|
||||
content: counter(step);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import "~/styles/globals.css";
|
||||
import "./globals.css";
|
||||
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { headers } from "next/headers";
|
||||
|
||||
151
src/app/page.tsx
151
src/app/page.tsx
@@ -1,12 +1,14 @@
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { BotIcon, ArrowRight, Sparkles, Brain, Microscope } from "lucide-react";
|
||||
import { Logo } from "~/components/logo";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { HeroSection } from "~/components/home/hero-section";
|
||||
import { FeaturesSection } from "~/components/home/features-section";
|
||||
import { CTASection } from "~/components/home/cta-section";
|
||||
|
||||
export default async function Home() {
|
||||
const session = await getServerAuthSession();
|
||||
const isLoggedIn = !!session;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative">
|
||||
@@ -41,131 +43,28 @@ export default async function Home() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="container mx-auto px-4 py-24">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div className="space-y-6">
|
||||
<div className="inline-flex rounded-lg bg-gradient-to-br from-primary/20 via-secondary/20 to-background p-1 mb-8">
|
||||
<span className="rounded-md bg-background/95 px-3 py-1 text-sm backdrop-blur">
|
||||
Now with Visual Experiment Designer
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight lg:text-6xl bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent">
|
||||
Streamline Your HRI Research
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
{!session ? (
|
||||
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||
<Link href="/auth/signup">
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||
<Link href="/dashboard">
|
||||
Go to Dashboard
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
|
||||
View on GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative aspect-square lg:aspect-video">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-secondary/20 to-background rounded-lg border shadow-xl" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<BotIcon className="h-32 w-32 text-primary/40" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Content Sections */}
|
||||
<div className="relative">
|
||||
<HeroSection isLoggedIn={isLoggedIn} />
|
||||
|
||||
{/* Dotted pattern for content sections */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground)) 1px, transparent 0),
|
||||
linear-gradient(to bottom, transparent, hsl(var(--background)))
|
||||
`,
|
||||
backgroundSize: '32px 32px, 100% 100%',
|
||||
maskImage: 'linear-gradient(to bottom, transparent, black 10%, black 90%, transparent)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<FeaturesSection />
|
||||
<CTASection isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="container mx-auto px-4 py-24 space-y-12">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent inline-block">
|
||||
Powerful Features for HRI Research
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-[600px] mx-auto">
|
||||
Everything you need to design, execute, and analyze your human-robot interaction experiments.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<CardHeader>
|
||||
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||
<Sparkles className="size-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Visual Experiment Design</CardTitle>
|
||||
<CardDescription>
|
||||
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<CardHeader>
|
||||
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||
<Brain className="size-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Real-time Control</CardTitle>
|
||||
<CardDescription>
|
||||
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<CardHeader>
|
||||
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||
<Microscope className="size-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Comprehensive Analysis</CardTitle>
|
||||
<CardDescription>
|
||||
Record, playback, and analyze experimental data with built-in annotation and export tools.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="container mx-auto px-4 py-24">
|
||||
<Card className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary via-primary to-secondary" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(0,0,0,0)_30%,rgba(0,0,0,0.15)_100%)]" />
|
||||
<CardContent className="relative p-12 flex flex-col items-center text-center space-y-6 text-primary-foreground">
|
||||
<BotIcon className="size-12 mb-4" />
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Ready to Transform Your Research?
|
||||
</h2>
|
||||
<p className="text-primary-foreground/90 max-w-[600px]">
|
||||
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
|
||||
</p>
|
||||
{!session ? (
|
||||
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||
<Link href="/auth/signup">Start Your Journey</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function StudySwitcher() {
|
||||
}
|
||||
|
||||
function StudySwitcherContent() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { isMobile, state } = useSidebar()
|
||||
const router = useRouter()
|
||||
const { studies, activeStudy, setActiveStudy, isLoading } = useStudy()
|
||||
|
||||
@@ -43,6 +43,8 @@ function StudySwitcherContent() {
|
||||
router.push("/dashboard/studies/new")
|
||||
}
|
||||
|
||||
const isCollapsed = state === "collapsed"
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -54,10 +56,12 @@ function StudySwitcherContent() {
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-accent/10">
|
||||
<Notebook className="size-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -76,10 +80,12 @@ function StudySwitcherContent() {
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Plus className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Create Study</span>
|
||||
<span className="truncate text-xs">Get started</span>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">Create Study</span>
|
||||
<span className="truncate text-xs">Get started</span>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -93,22 +99,29 @@ function StudySwitcherContent() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
className={cn(
|
||||
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
|
||||
isCollapsed && "justify-center p-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Notebook className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{activeStudy?.title ?? "Select Study"}
|
||||
</span>
|
||||
<span className="truncate text-xs">{activeStudy?.role ?? ""}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{activeStudy?.title ?? "Select Study"}
|
||||
</span>
|
||||
<span className="truncate text-xs">{activeStudy?.role ?? ""}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
className="min-w-56 rounded-lg"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
|
||||
@@ -30,62 +30,15 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { type ActionType } from "~/lib/experiments/types";
|
||||
|
||||
// Define parameter schemas for each action type
|
||||
const parameterSchemas = {
|
||||
move: z.object({
|
||||
position: z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
z: z.number(),
|
||||
}),
|
||||
speed: z.number().min(0).max(1),
|
||||
easing: z.enum(["linear", "easeIn", "easeOut", "easeInOut"]),
|
||||
}),
|
||||
speak: z.object({
|
||||
text: z.string().min(1),
|
||||
speed: z.number().min(0.5).max(2),
|
||||
pitch: z.number().min(0.5).max(2),
|
||||
volume: z.number().min(0).max(1),
|
||||
}),
|
||||
wait: z.object({
|
||||
duration: z.number().min(0),
|
||||
showCountdown: z.boolean(),
|
||||
}),
|
||||
input: z.object({
|
||||
type: z.enum(["button", "text", "number", "choice"]),
|
||||
prompt: z.string().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
timeout: z.number().nullable(),
|
||||
}),
|
||||
gesture: z.object({
|
||||
name: z.string().min(1),
|
||||
speed: z.number().min(0).max(1),
|
||||
intensity: z.number().min(0).max(1),
|
||||
}),
|
||||
record: z.object({
|
||||
type: z.enum(["start", "stop"]),
|
||||
streams: z.array(z.enum(["video", "audio", "sensors"])),
|
||||
}),
|
||||
condition: z.object({
|
||||
condition: z.string().min(1),
|
||||
trueActions: z.array(z.any()),
|
||||
falseActions: z.array(z.any()).optional(),
|
||||
}),
|
||||
loop: z.object({
|
||||
count: z.number().min(1),
|
||||
actions: z.array(z.any()),
|
||||
}),
|
||||
} satisfies Record<ActionType, z.ZodType<any>>;
|
||||
import { type ActionConfig } from "~/lib/experiments/plugin-actions";
|
||||
|
||||
interface ActionConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: ActionType;
|
||||
type: string;
|
||||
parameters: Record<string, any>;
|
||||
onSubmit: (parameters: Record<string, any>) => void;
|
||||
actionConfig: ActionConfig;
|
||||
}
|
||||
|
||||
export function ActionConfigDialog({
|
||||
@@ -94,11 +47,41 @@ export function ActionConfigDialog({
|
||||
type,
|
||||
parameters,
|
||||
onSubmit,
|
||||
actionConfig,
|
||||
}: ActionConfigDialogProps) {
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||
if (!actionConfig) return null;
|
||||
// Create a dynamic schema based on the action's parameters
|
||||
const createDynamicSchema = () => {
|
||||
if (!actionConfig) return z.object({});
|
||||
|
||||
const schema = parameterSchemas[type];
|
||||
const schemaFields: Record<string, z.ZodType<any>> = {};
|
||||
|
||||
for (const [key, prop] of Object.entries(actionConfig.defaultParameters)) {
|
||||
switch (typeof prop) {
|
||||
case "string":
|
||||
schemaFields[key] = z.string();
|
||||
break;
|
||||
case "number":
|
||||
schemaFields[key] = z.number();
|
||||
break;
|
||||
case "boolean":
|
||||
schemaFields[key] = z.boolean();
|
||||
break;
|
||||
case "object":
|
||||
if (Array.isArray(prop)) {
|
||||
schemaFields[key] = z.array(z.any());
|
||||
} else {
|
||||
schemaFields[key] = z.record(z.any());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
schemaFields[key] = z.any();
|
||||
}
|
||||
}
|
||||
|
||||
return z.object(schemaFields);
|
||||
};
|
||||
|
||||
const schema = createDynamicSchema();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: parameters,
|
||||
@@ -109,6 +92,104 @@ export function ActionConfigDialog({
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function renderField(key: string, value: any) {
|
||||
const fieldType = typeof value;
|
||||
|
||||
switch (fieldType) {
|
||||
case "string":
|
||||
if (value.length > 50) {
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
return (
|
||||
<FormField
|
||||
key={key}
|
||||
control={form.control}
|
||||
name={key}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{key}</FormLabel>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "object":
|
||||
if (Array.isArray(value)) {
|
||||
// TODO: Add array field handling
|
||||
return null;
|
||||
}
|
||||
// TODO: Add object field handling
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
@@ -119,280 +200,10 @@ export function ActionConfigDialog({
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{type === "move" && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.x"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>X Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.y"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Y Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.z"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Z Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="speed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Speed</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Movement speed (0-1)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="easing"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Easing</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select easing type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="linear">Linear</SelectItem>
|
||||
<SelectItem value="easeIn">Ease In</SelectItem>
|
||||
<SelectItem value="easeOut">Ease Out</SelectItem>
|
||||
<SelectItem value="easeInOut">Ease In Out</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Movement easing function
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
{Object.entries(actionConfig.defaultParameters).map(([key, value]) =>
|
||||
renderField(key, value)
|
||||
)}
|
||||
|
||||
{type === "speak" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter text to speak"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="speed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Speed</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
max="2"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pitch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pitch</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
max="2"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volume"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Volume</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "wait" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="duration"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Duration (ms)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Wait duration in milliseconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showCountdown"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Show Countdown
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Display a countdown timer during the wait
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add more action type configurations here */}
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</div>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -17,7 +17,7 @@ import ReactFlow, {
|
||||
} from "reactflow";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { type Step } from "~/lib/experiments/types";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { BUILT_IN_ACTIONS, getPluginActions, type ActionConfig } from "~/lib/experiments/plugin-actions";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
@@ -27,6 +27,7 @@ import { ActionItem } from "./action-item";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Undo, Redo, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
const nodeTypes = {
|
||||
@@ -51,35 +52,50 @@ export function ExperimentDesigner({
|
||||
readOnly = false,
|
||||
}: ExperimentDesignerProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
|
||||
// History management for undo/redo
|
||||
// Get available plugins
|
||||
const { data: plugins } = api.pluginStore.getPlugins.useQuery();
|
||||
const { data: installedPlugins } = api.pluginStore.getInstalledPlugins.useQuery();
|
||||
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
|
||||
|
||||
// Get available actions from installed plugins
|
||||
const installedPluginActions = plugins
|
||||
? getPluginActions(plugins.filter(p => installedPluginIds.includes(p.robotId)))
|
||||
: [];
|
||||
|
||||
// Combine built-in actions with plugin actions
|
||||
const availableActions = [...BUILT_IN_ACTIONS, ...installedPluginActions];
|
||||
|
||||
// History management
|
||||
const [history, setHistory] = useState<Step[][]>([defaultSteps]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
|
||||
const addToHistory = useCallback((newSteps: Step[]) => {
|
||||
setHistory((h) => {
|
||||
const newHistory = h.slice(0, historyIndex + 1);
|
||||
setHistory(prev => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1);
|
||||
return [...newHistory, newSteps];
|
||||
});
|
||||
setHistoryIndex((i) => i + 1);
|
||||
setHistoryIndex(prev => prev + 1);
|
||||
}, [historyIndex]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex((i) => i - 1);
|
||||
setSteps(history[historyIndex - 1]!);
|
||||
onChange?.(history[historyIndex - 1]!);
|
||||
setHistoryIndex(prev => prev - 1);
|
||||
const prevSteps = history[historyIndex - 1]!;
|
||||
setSteps(prevSteps);
|
||||
onChange?.(prevSteps);
|
||||
}
|
||||
}, [history, historyIndex, onChange]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
setHistoryIndex((i) => i + 1);
|
||||
setSteps(history[historyIndex + 1]!);
|
||||
onChange?.(history[historyIndex + 1]!);
|
||||
setHistoryIndex(prev => prev + 1);
|
||||
const nextSteps = history[historyIndex + 1]!;
|
||||
setSteps(nextSteps);
|
||||
onChange?.(nextSteps);
|
||||
}
|
||||
}, [history, historyIndex, onChange]);
|
||||
|
||||
@@ -99,8 +115,8 @@ export function ExperimentDesigner({
|
||||
);
|
||||
const actionIndex = stepIndex !== -1
|
||||
? newSteps[stepIndex]!.actions.findIndex(
|
||||
a => a.id === action.id
|
||||
)
|
||||
a => a.id === action.id
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (
|
||||
@@ -136,18 +152,16 @@ export function ExperimentDesigner({
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
setNodes((nds) => {
|
||||
const newNodes = applyNodeChanges(changes, nds);
|
||||
// Update selected node
|
||||
const selectedChange = changes.find((c) => c.type === "select");
|
||||
if (selectedChange) {
|
||||
const selected = newNodes.find((n) => n.id === selectedChange.id);
|
||||
setSelectedNode(selected ?? null);
|
||||
}
|
||||
return newNodes;
|
||||
});
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
const selectedChange = changes.find(
|
||||
(change) => change.type === "select"
|
||||
);
|
||||
if (selectedChange) {
|
||||
const node = nodes.find((n) => n.id === selectedChange.id);
|
||||
setSelectedNode(selectedChange.selected ? node : null);
|
||||
}
|
||||
},
|
||||
[]
|
||||
[nodes]
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
@@ -159,17 +173,11 @@ export function ExperimentDesigner({
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
const newEdge: Edge = {
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
source: connection.source ?? "",
|
||||
target: connection.target ?? "",
|
||||
type: "default",
|
||||
animated: true,
|
||||
};
|
||||
setEdges((eds) => [...eds, newEdge]);
|
||||
if (!connection.source || !connection.target) return;
|
||||
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const newSteps = [...steps];
|
||||
const sourceStep = newSteps.find((s) =>
|
||||
@@ -227,7 +235,7 @@ export function ExperimentDesigner({
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||
const actionConfig = availableActions.find((a) => a.type === type);
|
||||
if (!actionConfig) return;
|
||||
|
||||
const newAction = {
|
||||
@@ -251,8 +259,8 @@ export function ExperimentDesigner({
|
||||
);
|
||||
const actionIndex = stepIndex !== -1
|
||||
? newSteps[stepIndex]!.actions.findIndex(
|
||||
a => a.id === newAction.id
|
||||
)
|
||||
a => a.id === newAction.id
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (
|
||||
@@ -284,11 +292,24 @@ export function ExperimentDesigner({
|
||||
addToHistory([...steps, newStep]);
|
||||
onChange?.([...steps, newStep]);
|
||||
},
|
||||
[steps, onChange, reactFlowInstance, addToHistory]
|
||||
[steps, onChange, reactFlowInstance, addToHistory, availableActions]
|
||||
);
|
||||
|
||||
// Group actions by source
|
||||
const groupedActions = availableActions.reduce((acc, action) => {
|
||||
const source = action.pluginId ?
|
||||
plugins?.find(p => p.robotId === action.pluginId)?.name ?? action.pluginId :
|
||||
'Built-in Actions';
|
||||
|
||||
if (!acc[source]) {
|
||||
acc[source] = [];
|
||||
}
|
||||
acc[source].push(action);
|
||||
return acc;
|
||||
}, {} as Record<string, ActionConfig[]>);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex h-[calc(100vh-16rem)]", className)}>
|
||||
<div className={cn("relative flex h-full", className)}>
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
@@ -298,7 +319,7 @@ export function ExperimentDesigner({
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="absolute inset-y-0 left-0 z-30 w-80 overflow-hidden"
|
||||
>
|
||||
<Card className="flex h-full flex-col rounded-r-none border-r-0 shadow-2xl">
|
||||
<Card className="flex h-full flex-col rounded-lg border shadow-2xl">
|
||||
<Tabs defaultValue="actions" className="flex h-full flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<TabsList>
|
||||
@@ -316,23 +337,32 @@ export function ExperimentDesigner({
|
||||
</div>
|
||||
<TabsContent value="actions" className="flex-1 p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-4">
|
||||
{AVAILABLE_ACTIONS.map((action) => (
|
||||
<ActionItem
|
||||
key={action.type}
|
||||
type={action.type}
|
||||
title={action.title}
|
||||
description={action.description}
|
||||
icon={action.icon}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
action.type
|
||||
);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-6 p-4">
|
||||
{Object.entries(groupedActions).map(([source, actions]) => (
|
||||
<div key={source} className="space-y-2">
|
||||
<h3 className="px-2 text-sm font-medium text-muted-foreground">
|
||||
{source}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{actions.map((action) => (
|
||||
<ActionItem
|
||||
key={action.pluginId ? `${action.pluginId}:${action.type}` : action.type}
|
||||
type={action.type}
|
||||
title={action.title}
|
||||
description={action.description}
|
||||
icon={action.icon}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
action.type
|
||||
);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -343,7 +373,7 @@ export function ExperimentDesigner({
|
||||
{selectedNode ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">
|
||||
{AVAILABLE_ACTIONS.find((a) => a.type === selectedNode.data.type)?.title}
|
||||
{availableActions.find((a) => a.type === selectedNode.data.type)?.title}
|
||||
</h3>
|
||||
<pre className="rounded-lg bg-muted p-4 text-xs">
|
||||
{JSON.stringify(selectedNode.data.parameters, null, 2)}
|
||||
@@ -397,24 +427,24 @@ export function ExperimentDesigner({
|
||||
className="react-flow-wrapper"
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<Controls className="!left-auto !right-8" />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const action = AVAILABLE_ACTIONS.find(
|
||||
const action = availableActions.find(
|
||||
(a) => a.type === node.data.type
|
||||
);
|
||||
return action ? "hsl(var(--primary) / 0.5)" : "hsl(var(--muted))"
|
||||
}}
|
||||
maskColor="hsl(var(--background))"
|
||||
className="!bg-card/80 !border !border-border rounded-lg backdrop-blur"
|
||||
className="!bottom-8 !left-auto !right-8 !bg-card/80 !border !border-border rounded-lg backdrop-blur"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
/>
|
||||
<Panel position="top-center" className="flex gap-2 rounded-lg bg-background/95 px-4 py-2 shadow-md backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<Panel position="top-right" className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={undo}
|
||||
disabled={historyIndex === 0}
|
||||
@@ -422,28 +452,13 @@ export function ExperimentDesigner({
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={redo}
|
||||
disabled={historyIndex === history.length - 1}
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="mx-2 w-px bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => reactFlowInstance?.zoomIn()}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => reactFlowInstance?.zoomOut()}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { memo, useState } from "react";
|
||||
import { Handle, Position, type NodeProps } from "reactflow";
|
||||
import { motion } from "framer-motion";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { BUILT_IN_ACTIONS, getPluginActions } from "~/lib/experiments/plugin-actions";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -16,6 +16,7 @@ import { Settings, ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ActionConfigDialog } from "../action-config-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface ActionNodeData {
|
||||
type: string;
|
||||
@@ -26,11 +27,30 @@ interface ActionNodeData {
|
||||
export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) => {
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === data.type);
|
||||
|
||||
// Get available plugins
|
||||
const { data: plugins } = api.pluginStore.getPlugins.useQuery();
|
||||
const { data: installedPlugins } = api.pluginStore.getInstalledPlugins.useQuery();
|
||||
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
|
||||
|
||||
// Get available actions from installed plugins
|
||||
const installedPluginActions = plugins
|
||||
? getPluginActions(plugins.filter(p => installedPluginIds.includes(p.robotId)))
|
||||
: [];
|
||||
|
||||
// Combine built-in actions with plugin actions
|
||||
const availableActions = [...BUILT_IN_ACTIONS, ...installedPluginActions];
|
||||
const actionConfig = availableActions.find((a) => a.type === data.type);
|
||||
|
||||
if (!actionConfig) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-primary !border-primary-foreground"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
@@ -48,79 +68,47 @@ export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) =
|
||||
isHovered && "before:from-border/80 before:to-border/30",
|
||||
)}
|
||||
>
|
||||
<Card className="relative z-10 w-[250px] bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4">
|
||||
<Card className="relative z-10 min-w-[240px] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10 text-primary">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10">
|
||||
{actionConfig.icon}
|
||||
</div>
|
||||
<CardTitle className="text-sm font-medium leading-none">
|
||||
{actionConfig.title}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base">{actionConfig.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Configure Action</TooltipContent>
|
||||
</Tooltip>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<CardDescription className="text-xs">
|
||||
<CardContent>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{actionConfig.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn(
|
||||
"!h-3 !w-3 !border-2 !bg-background",
|
||||
"!border-border transition-colors duration-200",
|
||||
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="flex items-center gap-2">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
Input Connection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn(
|
||||
"!h-3 !w-3 !border-2 !bg-background",
|
||||
"!border-border transition-colors duration-200",
|
||||
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="flex items-center gap-2">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
Output Connection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-primary !border-primary-foreground"
|
||||
/>
|
||||
<ActionConfigDialog
|
||||
open={configOpen}
|
||||
onOpenChange={setConfigOpen}
|
||||
type={data.type as any}
|
||||
type={data.type}
|
||||
parameters={data.parameters}
|
||||
onSubmit={data.onChange ?? (() => {})}
|
||||
onSubmit={data.onChange ?? (() => { })}
|
||||
actionConfig={actionConfig}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
73
src/components/home/cta-section.tsx
Normal file
73
src/components/home/cta-section.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { BotIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
|
||||
interface CTASectionProps {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export function CTASection({ isLoggedIn }: CTASectionProps) {
|
||||
return (
|
||||
<section className="container mx-auto px-4 py-24">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="relative overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary via-primary to-secondary" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(0,0,0,0)_30%,rgba(0,0,0,0.15)_100%)]" />
|
||||
<CardContent className="relative p-12 flex flex-col items-center text-center space-y-6 text-primary-foreground">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
whileInView={{ scale: 1, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
>
|
||||
<BotIcon className="size-12 mb-4" />
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
className="text-3xl font-bold tracking-tight"
|
||||
>
|
||||
Ready to Transform Your Research?
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="text-primary-foreground/90 max-w-[600px]"
|
||||
>
|
||||
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
>
|
||||
{!isLoggedIn ? (
|
||||
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||
<Link href="/auth/signup">Start Your Journey</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
|
||||
<Link href="/dashboard">Go to Dashboard</Link>
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
67
src/components/home/features-section.tsx
Normal file
67
src/components/home/features-section.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Sparkles, Brain, Microscope } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Sparkles className="size-6 text-primary" />,
|
||||
title: "Visual Experiment Design",
|
||||
description: "Create and configure experiments using an intuitive drag-and-drop interface without extensive coding."
|
||||
},
|
||||
{
|
||||
icon: <Brain className="size-6 text-primary" />,
|
||||
title: "Real-time Control",
|
||||
description: "Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration."
|
||||
},
|
||||
{
|
||||
icon: <Microscope className="size-6 text-primary" />,
|
||||
title: "Comprehensive Analysis",
|
||||
description: "Record, playback, and analyze experimental data with built-in annotation and export tools."
|
||||
}
|
||||
];
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<section className="container mx-auto px-4 py-24 space-y-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-center space-y-4"
|
||||
>
|
||||
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent inline-block">
|
||||
Powerful Features for HRI Research
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-[600px] mx-auto">
|
||||
Everything you need to design, execute, and analyze your human-robot interaction experiments.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1, duration: 0.5 }}
|
||||
>
|
||||
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<CardHeader>
|
||||
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
|
||||
{feature.icon}
|
||||
</div>
|
||||
<CardTitle>{feature.title}</CardTitle>
|
||||
<CardDescription>{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
101
src/components/home/hero-section.tsx
Normal file
101
src/components/home/hero-section.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { BotIcon, ArrowRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface HeroSectionProps {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export function HeroSection({ isLoggedIn }: HeroSectionProps) {
|
||||
return (
|
||||
<section className="relative">
|
||||
{/* Hero gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-primary/5 to-background">
|
||||
<div className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 50% 50%, hsl(var(--primary)/.08) 0%, transparent 50%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-24 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="grid lg:grid-cols-2 gap-12 items-center"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="inline-flex rounded-lg bg-gradient-to-br from-primary/20 via-secondary/20 to-background p-1 mb-8"
|
||||
>
|
||||
<span className="rounded-md bg-background/95 px-3 py-1 text-sm backdrop-blur">
|
||||
Now with Visual Experiment Designer
|
||||
</span>
|
||||
</motion.div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
className="text-4xl font-bold tracking-tight lg:text-6xl bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent"
|
||||
>
|
||||
Streamline Your HRI Research
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="text-xl text-muted-foreground"
|
||||
>
|
||||
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
className="flex flex-col sm:flex-row gap-4 pt-4"
|
||||
>
|
||||
{!isLoggedIn ? (
|
||||
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||
<Link href="/auth/signup">
|
||||
Get Started
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
|
||||
<Link href="/dashboard">
|
||||
Go to Dashboard
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
|
||||
View on GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="relative aspect-square lg:aspect-video"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-secondary/20 to-background rounded-lg border shadow-xl" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<BotIcon className="h-32 w-32 text-primary/40" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Beaker,
|
||||
Home,
|
||||
Settings2,
|
||||
User,
|
||||
Microscope,
|
||||
Users,
|
||||
Plus,
|
||||
FlaskConical
|
||||
FlaskConical,
|
||||
Bot
|
||||
} from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
@@ -42,16 +40,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
title: "Studies",
|
||||
url: "/dashboard/studies",
|
||||
icon: Microscope,
|
||||
items: [
|
||||
{
|
||||
title: "All Studies",
|
||||
url: "/dashboard/studies",
|
||||
},
|
||||
{
|
||||
title: "Create Study",
|
||||
url: "/dashboard/studies/new",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Robot Store",
|
||||
url: "/dashboard/store",
|
||||
icon: Bot,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -62,34 +55,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
title: "Participants",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants`,
|
||||
icon: Users,
|
||||
items: [
|
||||
{
|
||||
title: "All Participants",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants`,
|
||||
},
|
||||
{
|
||||
title: "Add Participant",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants/new`,
|
||||
// Only show if user is admin
|
||||
hidden: activeStudy.role !== "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Experiments",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||
icon: FlaskConical,
|
||||
items: [
|
||||
{
|
||||
title: "All Experiments",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||
},
|
||||
{
|
||||
title: "Create Experiment",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments/new`,
|
||||
hidden: !["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"].map(r => r.toLowerCase()).includes(activeStudy.role.toLowerCase()),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
@@ -100,22 +70,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
title: "Settings",
|
||||
url: "/dashboard/settings",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{
|
||||
title: "Account",
|
||||
url: "/dashboard/account",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: "/dashboard/settings/team",
|
||||
},
|
||||
{
|
||||
title: "Billing",
|
||||
url: "/dashboard/settings/billing",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
const navItems = [...baseNavItems, ...studyNavItems, ...settingsNavItems]
|
||||
|
||||
@@ -8,16 +8,19 @@ import { Logo } from "~/components/logo"
|
||||
export function Header() {
|
||||
return (
|
||||
<div className="sticky top-0 z-40 w-full">
|
||||
<header className="mx-2 mt-2 flex h-14 items-center justify-between rounded-lg border bg-gradient-to-r from-[hsl(var(--sidebar-gradient-from))] to-[hsl(var(--sidebar-gradient-to))] px-6 shadow-sm md:ml-0">
|
||||
<header
|
||||
data-nav="header"
|
||||
className="mx-2 mt-2 flex h-14 items-center justify-between rounded-lg border shadow-sm md:ml-0 px-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-2 text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-text))]/10" />
|
||||
<Separator orientation="vertical" className="h-4 bg-[hsl(var(--sidebar-text))]/10" />
|
||||
<SidebarTrigger className="-ml-2 text-[hsl(var(--sidebar-foreground))] hover:bg-[hsl(var(--sidebar-hover))]/20" />
|
||||
<Separator orientation="vertical" className="h-4 bg-[hsl(var(--sidebar-border))]" />
|
||||
<BreadcrumbNav />
|
||||
</div>
|
||||
<Logo
|
||||
href="/dashboard"
|
||||
className="text-[hsl(var(--sidebar-text))]"
|
||||
iconClassName="text-[hsl(var(--sidebar-text-muted))]"
|
||||
className="text-[hsl(var(--sidebar-foreground))]"
|
||||
iconClassName="text-[hsl(var(--sidebar-muted))]"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "~/components/ui/collapsible"
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "~/components/ui/sidebar"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export function NavMain({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Find the most specific matching route
|
||||
const activeItem = items
|
||||
.filter(item => {
|
||||
if (item.url === "/dashboard") {
|
||||
return pathname === "/dashboard"
|
||||
}
|
||||
return pathname.startsWith(item.url)
|
||||
})
|
||||
.sort((a, b) => b.url.length - a.url.length)[0]
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string
|
||||
url: string
|
||||
icon?: LucideIcon
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
<SidebarMenu className="pt-2">
|
||||
{items.map((item) => {
|
||||
const isActive = item.url === activeItem?.url
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.url}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive}
|
||||
tooltip={item.title}
|
||||
className={cn(
|
||||
"relative flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm outline-none transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
"group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:justify-center",
|
||||
isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link href={item.url} className="flex items-center gap-2 w-full group-data-[collapsible=icon]:justify-center">
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate group-data-[collapsible=icon]:hidden">{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ChevronsUpDown, LogOut, Settings, User } from "lucide-react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useSession, signOut } from "next-auth/react"
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
|
||||
@@ -20,9 +20,13 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "~/components/ui/sidebar"
|
||||
import { Avatar, AvatarFallback } from "~/components/ui/avatar"
|
||||
import { useSidebar } from "~/components/ui/sidebar"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
export function NavUser() {
|
||||
const { data: session, status } = useSession()
|
||||
const { state } = useSidebar()
|
||||
const isCollapsed = state === "collapsed"
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
@@ -30,15 +34,20 @@ export function NavUser() {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="animate-pulse"
|
||||
className={cn(
|
||||
"animate-pulse",
|
||||
isCollapsed && "justify-center p-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-accent/10">
|
||||
<User className="size-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="grid flex-1 gap-1">
|
||||
<div className="h-4 w-24 rounded bg-sidebar-accent/10" />
|
||||
<div className="h-3 w-16 rounded bg-sidebar-accent/10" />
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -56,7 +65,10 @@ export function NavUser() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
className={cn(
|
||||
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
|
||||
isCollapsed && "justify-center p-0"
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
{session.user.image ? (
|
||||
@@ -79,19 +91,23 @@ export function NavUser() {
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session.user.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs text-sidebar-muted">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session.user.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs text-sidebar-muted">
|
||||
{session.user.email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
className="min-w-56 rounded-lg"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
@@ -138,11 +154,12 @@ export function NavUser() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/api/auth/signout">
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Sign out
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => signOut({ callbackUrl: "/auth/signin" })}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<LogOut className="mr-2 size-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
24
src/components/providers/index.tsx
Normal file
24
src/components/providers/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { StudyProvider } from "./study-provider";
|
||||
import { PluginStoreProvider } from "./plugin-store-provider";
|
||||
import { Toaster } from "~/components/ui/toaster";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<PluginStoreProvider>
|
||||
<StudyProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</StudyProvider>
|
||||
</PluginStoreProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
50
src/components/providers/plugin-store-provider.tsx
Normal file
50
src/components/providers/plugin-store-provider.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
|
||||
interface PluginStoreContextType {
|
||||
plugins: RobotPlugin[];
|
||||
selectedPlugin?: RobotPlugin;
|
||||
selectPlugin: (robotId: string) => void;
|
||||
setPlugins: (plugins: RobotPlugin[]) => void;
|
||||
}
|
||||
|
||||
const PluginStoreContext = createContext<PluginStoreContextType | undefined>(undefined);
|
||||
|
||||
export function PluginStoreProvider({
|
||||
children,
|
||||
initialPlugins = [],
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialPlugins?: RobotPlugin[];
|
||||
}) {
|
||||
const [plugins, setPlugins] = useState<RobotPlugin[]>(initialPlugins);
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<RobotPlugin>();
|
||||
|
||||
const selectPlugin = (robotId: string) => {
|
||||
const plugin = plugins.find(p => p.robotId === robotId);
|
||||
setSelectedPlugin(plugin);
|
||||
};
|
||||
|
||||
return (
|
||||
<PluginStoreContext.Provider
|
||||
value={{
|
||||
plugins,
|
||||
selectedPlugin,
|
||||
selectPlugin,
|
||||
setPlugins,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PluginStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePluginStore() {
|
||||
const context = useContext(PluginStoreContext);
|
||||
if (!context) {
|
||||
throw new Error("usePluginStore must be used within a PluginStoreProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
123
src/components/store/add-repository-dialog.tsx
Normal file
123
src/components/store/add-repository-dialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
export function AddRepositoryDialog() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
const { toast } = useToast();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const addRepository = api.pluginStore.addRepository.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Repository added successfully",
|
||||
});
|
||||
setIsOpen(false);
|
||||
setUrl("");
|
||||
|
||||
// Invalidate and refetch all plugin store queries
|
||||
await Promise.all([
|
||||
utils.pluginStore.getRepositories.invalidate(),
|
||||
utils.pluginStore.getPlugins.invalidate(),
|
||||
utils.pluginStore.getInstalledPlugins.invalidate(),
|
||||
]);
|
||||
|
||||
// Force refetch
|
||||
await Promise.all([
|
||||
utils.pluginStore.getRepositories.refetch(),
|
||||
utils.pluginStore.getPlugins.refetch(),
|
||||
utils.pluginStore.getInstalledPlugins.refetch(),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to add repository:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to add repository",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddRepository = async () => {
|
||||
if (!url) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Please enter a repository URL",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await addRepository.mutateAsync({ url });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Repository
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Plugin Repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the URL of a plugin repository. The repository must contain a repository.json file and follow the HRIStudio plugin repository structure.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Example repository URL:
|
||||
<code className="ml-2 rounded bg-muted px-1.5 py-0.5">
|
||||
https://soconnor0919.github.io/robot-plugins
|
||||
</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
placeholder="Enter repository URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleAddRepository}
|
||||
disabled={isLoading || addRepository.isLoading}
|
||||
>
|
||||
{isLoading || addRepository.isLoading ? "Adding..." : "Add Repository"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
254
src/components/store/plugin-browser.tsx
Normal file
254
src/components/store/plugin-browser.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type RepositoryMetadata, type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Search, Filter } from "lucide-react";
|
||||
import { RepositorySection } from "./repository-section";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { RobotGrid } from "./robot-grid";
|
||||
import { RobotDetails } from "./robot-details";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
|
||||
interface PluginBrowserProps {
|
||||
repositories: RepositoryMetadata[];
|
||||
initialPlugins: RobotPlugin[];
|
||||
}
|
||||
|
||||
function RobotSkeleton() {
|
||||
return (
|
||||
<div className="flex gap-3 rounded-lg border p-4">
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginBrowser({ repositories, initialPlugins }: PluginBrowserProps) {
|
||||
// State
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRepository, setSelectedRepository] = useState<string>("all");
|
||||
const [showInstalled, setShowInstalled] = useState<boolean>(true);
|
||||
const [showAvailable, setShowAvailable] = useState<boolean>(true);
|
||||
const [selectedRobot, setSelectedRobot] = useState<RobotPlugin | null>(
|
||||
initialPlugins[0] ?? null
|
||||
);
|
||||
|
||||
// Queries
|
||||
const { data: installedPlugins, isLoading: isLoadingInstalled } = api.pluginStore.getInstalledPlugins.useQuery(undefined, {
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const { data: plugins, isLoading: isLoadingPlugins } = api.pluginStore.getPlugins.useQuery(undefined, {
|
||||
initialData: initialPlugins,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const installedPluginIds = installedPlugins?.map(p => p.robotId) ?? [];
|
||||
|
||||
// Loading state
|
||||
const isLoading = isLoadingInstalled || isLoadingPlugins;
|
||||
|
||||
// Filter plugins
|
||||
const filteredPlugins = plugins.filter(plugin => {
|
||||
// Repository filter
|
||||
if (selectedRepository !== "all" && plugin.repositoryId !== selectedRepository) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Installation status filter
|
||||
const isInstalled = installedPluginIds.includes(plugin.robotId);
|
||||
if (!showInstalled && isInstalled) return false;
|
||||
if (!showAvailable && !isInstalled) return false;
|
||||
|
||||
// Search query filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
plugin.name.toLowerCase().includes(query) ||
|
||||
plugin.description?.toLowerCase().includes(query) ||
|
||||
plugin.platform.toLowerCase().includes(query) ||
|
||||
plugin.manufacturer.name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="plugins" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="plugins">Robots</TabsTrigger>
|
||||
<TabsTrigger value="repositories">Repositories</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plugins">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Robot Plugins</CardTitle>
|
||||
<CardDescription>
|
||||
Browse and manage robot plugins from your configured repositories
|
||||
</CardDescription>
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search robots..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedRepository}
|
||||
onValueChange={setSelectedRepository}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select Repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Repositories</SelectItem>
|
||||
{repositories.map((repo) => (
|
||||
<SelectItem key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Show</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showInstalled}
|
||||
onCheckedChange={setShowInstalled}
|
||||
>
|
||||
Installed
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showAvailable}
|
||||
onCheckedChange={setShowAvailable}
|
||||
>
|
||||
Available
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
|
||||
{/* Left Pane - Robot List */}
|
||||
<div className="overflow-y-auto rounded-lg pr-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<RobotSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RobotGrid
|
||||
plugins={filteredPlugins}
|
||||
installedPluginIds={installedPluginIds}
|
||||
selectedRobotId={selectedRobot?.robotId}
|
||||
onSelectRobot={setSelectedRobot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Pane - Robot Details */}
|
||||
<div className="overflow-y-auto rounded-lg border bg-card">
|
||||
{isLoading ? (
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedRobot && (
|
||||
<RobotDetails
|
||||
robot={selectedRobot}
|
||||
isInstalled={installedPluginIds.includes(selectedRobot.robotId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="repositories">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plugin Repositories</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your robot plugin sources
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{repositories.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
|
||||
<Bot className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium">No Repositories Added</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a repository using the button above
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<RepositorySection repositories={repositories} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
98
src/components/store/repository-card.tsx
Normal file
98
src/components/store/repository-card.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { type RepositoryMetadata } from "~/lib/plugin-store/types";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Bot, Star, Download, Package, Calendar } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Image from "next/image";
|
||||
|
||||
interface RepositoryCardProps {
|
||||
repository: RepositoryMetadata;
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RepositoryCard({ repository, onRemove }: RepositoryCardProps) {
|
||||
const lastUpdated = new Date(repository.lastUpdated);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative aspect-square h-12 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{repository.assets?.logo ? (
|
||||
<Image
|
||||
src={repository.assets.logo}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-contain p-1.5"
|
||||
/>
|
||||
) : repository.assets?.icon ? (
|
||||
<Image
|
||||
src={repository.assets.icon}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="truncate">{repository.name}</span>
|
||||
{repository.official && (
|
||||
<Badge variant="default" className="shrink-0 text-xs">Official</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-2">{repository.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Star className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.stars ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.downloads ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.plugins ?? 0} plugins</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Updated {formatDistanceToNow(lastUpdated, { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{repository.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{onRemove && !repository.official && (
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onRemove(repository.id)}
|
||||
>
|
||||
Remove Repository
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
339
src/components/store/repository-section.tsx
Normal file
339
src/components/store/repository-section.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { type RepositoryMetadata } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Star, Download, Package, Calendar } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface RepositorySectionProps {
|
||||
repositories: RepositoryMetadata[];
|
||||
}
|
||||
|
||||
function RepositoryListItem({
|
||||
repository,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onRemove,
|
||||
}: {
|
||||
repository: RepositoryMetadata;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onRemove?: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-card ring-2 ring-primary/10"
|
||||
: "hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{repository.assets?.logo ? (
|
||||
<Image
|
||||
src={repository.assets.logo}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : repository.assets?.icon ? (
|
||||
<Image
|
||||
src={repository.assets.icon}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="line-clamp-1 font-semibold tracking-tight">{repository.name}</h3>
|
||||
{repository.official && (
|
||||
<Badge variant="default" className="shrink-0">Official</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{repository.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>{repository.stats?.stars ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3" />
|
||||
<span>{repository.stats?.downloads ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3" />
|
||||
<span>{repository.stats?.plugins ?? 0} plugins</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryDetails({ repository, onRemove }: { repository: RepositoryMetadata; onRemove?: (id: string) => void }) {
|
||||
return (
|
||||
<div className="overflow-y-auto rounded-lg border bg-card">
|
||||
<div className="border-b p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{repository.name}</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{repository.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={repository.urls.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View Repository
|
||||
</a>
|
||||
</Button>
|
||||
{repository.urls.git && (
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={repository.urls.git}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && !repository.official && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onRemove(repository.id)}
|
||||
>
|
||||
Remove Repository
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{repository.stats?.plugins ?? 0} plugins</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Updated {formatDistanceToNow(new Date(repository.lastUpdated), { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="plugins">Plugins</TabsTrigger>
|
||||
<TabsTrigger value="compatibility">Compatibility</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
{repository.assets?.banner && (
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-lg border">
|
||||
<Image
|
||||
src={repository.assets.banner}
|
||||
alt={repository.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Author</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Name: </span>
|
||||
<span>{repository.author.name}</span>
|
||||
</div>
|
||||
{repository.author.organization && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Organization: </span>
|
||||
<span>{repository.author.organization}</span>
|
||||
</div>
|
||||
)}
|
||||
{repository.author.url && (
|
||||
<a
|
||||
href={repository.author.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Tags</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{repository.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="plugins" className="mt-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Available Plugins</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This repository contains {repository.stats?.plugins ?? 0} robot plugins.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compatibility" className="mt-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">HRIStudio Compatibility</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Minimum Version: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{repository.compatibility.hristudio.min}
|
||||
</code>
|
||||
</div>
|
||||
{repository.compatibility.hristudio.recommended && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Recommended Version: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{repository.compatibility.hristudio.recommended}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repository.compatibility.ros2 && (
|
||||
<div className="mt-4 rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">ROS 2 Compatibility</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Supported Distributions: </span>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{repository.compatibility.ros2.distributions.map((dist) => (
|
||||
<Badge key={dist} variant="secondary">
|
||||
{dist}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{repository.compatibility.ros2.recommended && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Recommended Distribution: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{repository.compatibility.ros2.recommended}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepositorySection({ repositories }: RepositorySectionProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
const [selectedRepository, setSelectedRepository] = useState<RepositoryMetadata | null>(
|
||||
repositories[0] ?? null
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const removeRepository = api.pluginStore.removeRepository.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Repository removed successfully",
|
||||
});
|
||||
// Invalidate all plugin store queries
|
||||
utils.pluginStore.getRepositories.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to remove repository:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to remove repository",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveRepository = async (id: string) => {
|
||||
if (isRemoving) return;
|
||||
|
||||
try {
|
||||
setIsRemoving(true);
|
||||
await removeRepository.mutateAsync({ id });
|
||||
} finally {
|
||||
setIsRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!repositories.length) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-24rem)] items-center justify-center">
|
||||
<p className="text-muted-foreground">No repositories added</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
|
||||
{/* Left Pane - Repository List */}
|
||||
<div className="overflow-y-auto rounded-lg pr-4">
|
||||
<div className="space-y-3">
|
||||
{repositories.map((repository) => (
|
||||
<RepositoryListItem
|
||||
key={repository.id}
|
||||
repository={repository}
|
||||
isSelected={selectedRepository?.id === repository.id}
|
||||
onSelect={() => setSelectedRepository(repository)}
|
||||
onRemove={handleRemoveRepository}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Pane - Repository Details */}
|
||||
{selectedRepository && (
|
||||
<RepositoryDetails
|
||||
repository={selectedRepository}
|
||||
onRemove={handleRemoveRepository}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
src/components/store/robot-details.tsx
Normal file
412
src/components/store/robot-details.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Download, Info, Zap, Battery, Scale, Ruler, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
interface RobotDetailsProps {
|
||||
robot: RobotPlugin;
|
||||
isInstalled: boolean;
|
||||
}
|
||||
|
||||
function RobotHeader({ robot, isInstalled }: RobotDetailsProps) {
|
||||
const { toast } = useToast();
|
||||
const utils = api.useUtils();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showUninstallDialog, setShowUninstallDialog] = useState(false);
|
||||
|
||||
const installPlugin = api.pluginStore.installPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${robot.name} installed successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to install plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to install plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallPlugin = api.pluginStore.uninstallPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${robot.name} uninstalled successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to uninstall plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to uninstall plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await installPlugin.mutateAsync({
|
||||
robotId: robot.robotId,
|
||||
repositoryId: "hristudio-official", // TODO: Get from context
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await uninstallPlugin.mutateAsync({ robotId: robot.robotId });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowUninstallDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{robot.name}</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{robot.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={robot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
{isInstalled ? (
|
||||
<AlertDialog open={showUninstallDialog} onOpenChange={setShowUninstallDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Uninstall
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Uninstall Robot</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to uninstall {robot.name}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUninstall}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isProcessing ? "Uninstalling..." : "Uninstall"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isProcessing ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotImages({ robot }: { robot: RobotPlugin }) {
|
||||
const [showLeftFade, setShowLeftFade] = useState(false);
|
||||
const [showRightFade, setShowRightFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const hasLeftScroll = el.scrollLeft > 0;
|
||||
const hasRightScroll = el.scrollLeft < (el.scrollWidth - el.clientWidth);
|
||||
|
||||
setShowLeftFade(hasLeftScroll);
|
||||
setShowRightFade(hasRightScroll);
|
||||
};
|
||||
|
||||
// Check initial scroll
|
||||
checkScroll();
|
||||
|
||||
// Add scroll listener
|
||||
el.addEventListener('scroll', checkScroll);
|
||||
// Add resize listener to handle window changes
|
||||
window.addEventListener('resize', checkScroll);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={scrollRef} className="overflow-x-auto pb-4">
|
||||
<div className="flex gap-4">
|
||||
{/* Main Image */}
|
||||
<div className="relative h-[300px] aspect-video shrink-0 overflow-hidden rounded-lg border bg-muted">
|
||||
<Image
|
||||
src={robot.assets.images.main}
|
||||
alt={robot.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Angle Images */}
|
||||
{robot.assets.images.angles && (
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
|
||||
<div
|
||||
key={angle}
|
||||
className="relative h-[300px] aspect-square shrink-0 overflow-hidden rounded-lg border bg-muted"
|
||||
>
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${robot.name} - ${angle} view`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/50 to-transparent p-4">
|
||||
<span className="text-xs font-medium text-white capitalize">
|
||||
{angle} View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fade indicators */}
|
||||
{showLeftFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent" />
|
||||
)}
|
||||
{showRightFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotSpecs({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Physical Specifications</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Capabilities</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{robot.specs.capabilities.map((capability) => (
|
||||
<Badge key={capability} variant="secondary">
|
||||
{capability}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">ROS 2 Configuration</h4>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Namespace: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.namespace}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Node Prefix: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.nodePrefix}
|
||||
</code>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-muted-foreground">Default Topics:</span>
|
||||
<div className="grid gap-1.5 pl-4">
|
||||
{Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
|
||||
<div key={name}>
|
||||
<span className="text-muted-foreground">{name}: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">{topic}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotActions({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{robot.actions.map((action) => (
|
||||
<div key={action.actionId} className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="font-medium">{action.title}</h4>
|
||||
<Badge variant="secondary">{action.type}</Badge>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<h5 className="text-sm font-medium text-muted-foreground">Parameters:</h5>
|
||||
<div className="grid gap-2 pl-4">
|
||||
{Object.entries(action.parameters.properties).map(([name, prop]) => (
|
||||
<div key={name} className="text-sm">
|
||||
<span className="font-medium">{prop.title}</span>
|
||||
{prop.unit && (
|
||||
<span className="text-muted-foreground"> ({prop.unit})</span>
|
||||
)}
|
||||
{prop.description && (
|
||||
<p className="mt-0.5 text-muted-foreground">{prop.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RobotDetails({ robot, isInstalled }: RobotDetailsProps) {
|
||||
return (
|
||||
<>
|
||||
<RobotHeader robot={robot} isInstalled={isInstalled} />
|
||||
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="specs">Specifications</TabsTrigger>
|
||||
<TabsTrigger value="actions">Actions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<RobotImages robot={robot} />
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Documentation</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<a
|
||||
href={robot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
User Manual
|
||||
</a>
|
||||
{robot.documentation.apiReference && (
|
||||
<a
|
||||
href={robot.documentation.apiReference}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
API Reference
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="specs" className="mt-6">
|
||||
<RobotSpecs robot={robot} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="mt-6">
|
||||
<RobotActions robot={robot} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
src/components/store/robot-grid.tsx
Normal file
257
src/components/store/robot-grid.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Download, Info, Zap, Battery, Scale, Ruler, Check, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
interface RobotGridProps {
|
||||
plugins: RobotPlugin[];
|
||||
installedPluginIds?: string[];
|
||||
selectedRobotId?: string;
|
||||
onSelectRobot: (robot: RobotPlugin) => void;
|
||||
}
|
||||
|
||||
function RobotCard({
|
||||
plugin,
|
||||
isInstalled,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
plugin: RobotPlugin;
|
||||
isInstalled: boolean;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const utils = api.useUtils();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showUninstallDialog, setShowUninstallDialog] = useState(false);
|
||||
|
||||
const installPlugin = api.pluginStore.installPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${plugin.name} installed successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to install plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to install plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallPlugin = api.pluginStore.uninstallPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${plugin.name} uninstalled successfully`,
|
||||
});
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to uninstall plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to uninstall plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleInstall = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await installPlugin.mutateAsync({
|
||||
robotId: plugin.robotId,
|
||||
repositoryId: "hristudio-official", // TODO: Get from context
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async () => {
|
||||
if (isProcessing) return;
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
await uninstallPlugin.mutateAsync({ robotId: plugin.robotId });
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowUninstallDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-card ring-2 ring-primary/10"
|
||||
: "hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{plugin.assets.logo ? (
|
||||
<Image
|
||||
src={plugin.assets.logo}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : plugin.assets.thumbnailUrl ? (
|
||||
<Image
|
||||
src={plugin.assets.thumbnailUrl}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="line-clamp-1 font-semibold tracking-tight">{plugin.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{plugin.platform}
|
||||
</Badge>
|
||||
{isInstalled && (
|
||||
<Badge variant="default" className="shrink-0 bg-primary">
|
||||
Installed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{plugin.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Battery className="h-3 w-3" />
|
||||
<span>{plugin.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isInstalled ? (
|
||||
<AlertDialog open={showUninstallDialog} onOpenChange={setShowUninstallDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowUninstallDialog(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="ml-2">Uninstall</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Uninstall Robot</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to uninstall {plugin.name}? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleUninstall}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isProcessing ? "Uninstalling..." : "Uninstall"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleInstall}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
"Installing..."
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RobotGrid({ plugins, installedPluginIds = [], selectedRobotId, onSelectRobot }: RobotGridProps) {
|
||||
if (!plugins.length) {
|
||||
return (
|
||||
<div className="flex h-[400px] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="mx-auto h-16 w-16 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium">No Robots Found</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Try adjusting your filters or adding more repositories.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{plugins.map((plugin) => (
|
||||
<RobotCard
|
||||
key={plugin.robotId}
|
||||
plugin={plugin}
|
||||
isInstalled={installedPluginIds.includes(plugin.robotId)}
|
||||
isSelected={plugin.robotId === selectedRobotId}
|
||||
onSelect={() => onSelectRobot(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
439
src/components/store/robot-list.tsx
Normal file
439
src/components/store/robot-list.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
"use client";
|
||||
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Bot, Download, Info, Zap, Battery, Scale, Ruler } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface RobotListProps {
|
||||
plugins: RobotPlugin[];
|
||||
}
|
||||
|
||||
function RobotListItem({
|
||||
plugin,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
plugin: RobotPlugin;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer gap-3 rounded-lg border p-4 transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-card ring-2 ring-primary/10"
|
||||
: "hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="relative aspect-square h-20 shrink-0 overflow-hidden rounded-md border bg-muted">
|
||||
{plugin.assets.logo ? (
|
||||
<Image
|
||||
src={plugin.assets.logo}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
) : plugin.assets.thumbnailUrl ? (
|
||||
<Image
|
||||
src={plugin.assets.thumbnailUrl}
|
||||
alt={plugin.name}
|
||||
fill
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Bot className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="line-clamp-1 font-semibold tracking-tight">{plugin.name}</h3>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{plugin.platform}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{plugin.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Battery className="h-3 w-3" />
|
||||
<span>{plugin.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotHeader({ robot }: { robot: RobotPlugin }) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const installPlugin = api.pluginStore.installPlugin.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `${robot.name} installed successfully`,
|
||||
});
|
||||
// Invalidate both queries to refresh the data
|
||||
utils.pluginStore.getInstalledPlugins.invalidate();
|
||||
utils.pluginStore.getPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to install plugin:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to install plugin",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
try {
|
||||
setIsInstalling(true);
|
||||
await installPlugin.mutateAsync({
|
||||
robotId: robot.robotId,
|
||||
repositoryId: "hristudio-official", // TODO: Get from context
|
||||
});
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b p-6">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">{robot.name}</h2>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{robot.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href={robot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling || installPlugin.isLoading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isInstalling || installPlugin.isLoading ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotImages({ robot }: { robot: RobotPlugin }) {
|
||||
const [showLeftFade, setShowLeftFade] = useState(false);
|
||||
const [showRightFade, setShowRightFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const hasLeftScroll = el.scrollLeft > 0;
|
||||
const hasRightScroll = el.scrollLeft < (el.scrollWidth - el.clientWidth);
|
||||
|
||||
setShowLeftFade(hasLeftScroll);
|
||||
setShowRightFade(hasRightScroll);
|
||||
};
|
||||
|
||||
// Check initial scroll
|
||||
checkScroll();
|
||||
|
||||
// Add scroll listener
|
||||
el.addEventListener('scroll', checkScroll);
|
||||
// Add resize listener to handle window changes
|
||||
window.addEventListener('resize', checkScroll);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkScroll);
|
||||
window.removeEventListener('resize', checkScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={scrollRef} className="overflow-x-auto pb-4">
|
||||
<div className="flex gap-4">
|
||||
{/* Main Image */}
|
||||
<div className="relative h-[300px] aspect-video shrink-0 overflow-hidden rounded-lg border bg-muted">
|
||||
<Image
|
||||
src={robot.assets.images.main}
|
||||
alt={robot.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Angle Images */}
|
||||
{robot.assets.images.angles && (
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(robot.assets.images.angles).map(([angle, url]) => url && (
|
||||
<div
|
||||
key={angle}
|
||||
className="relative h-[300px] aspect-square shrink-0 overflow-hidden rounded-lg border bg-muted"
|
||||
>
|
||||
<Image
|
||||
src={url}
|
||||
alt={`${robot.name} - ${angle} view`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/50 to-transparent p-4">
|
||||
<span className="text-xs font-medium text-white capitalize">
|
||||
{angle} View
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fade indicators */}
|
||||
{showLeftFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent" />
|
||||
)}
|
||||
{showRightFade && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotSpecs({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Physical Specifications</h4>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{robot.specs.dimensions.length}m × {robot.specs.dimensions.width}m × {robot.specs.dimensions.height}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.dimensions.weight}kg</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.maxSpeed}m/s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Battery className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">{robot.specs.batteryLife}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Capabilities</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{robot.specs.capabilities.map((capability) => (
|
||||
<Badge key={capability} variant="secondary">
|
||||
{capability}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">ROS 2 Configuration</h4>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Namespace: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.namespace}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Node Prefix: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">
|
||||
{robot.ros2Config.nodePrefix}
|
||||
</code>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-muted-foreground">Default Topics:</span>
|
||||
<div className="grid gap-1.5 pl-4">
|
||||
{Object.entries(robot.ros2Config.defaultTopics).map(([name, topic]) => (
|
||||
<div key={name}>
|
||||
<span className="text-muted-foreground">{name}: </span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">{topic}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RobotActions({ robot }: { robot: RobotPlugin }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{robot.actions.map((action) => (
|
||||
<div key={action.actionId} className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="font-medium">{action.title}</h4>
|
||||
<Badge variant="secondary">{action.type}</Badge>
|
||||
</div>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<h5 className="text-sm font-medium text-muted-foreground">Parameters:</h5>
|
||||
<div className="grid gap-2 pl-4">
|
||||
{Object.entries(action.parameters.properties).map(([name, prop]) => (
|
||||
<div key={name} className="text-sm">
|
||||
<span className="font-medium">{prop.title}</span>
|
||||
{prop.unit && (
|
||||
<span className="text-muted-foreground"> ({prop.unit})</span>
|
||||
)}
|
||||
{prop.description && (
|
||||
<p className="mt-0.5 text-muted-foreground">{prop.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RobotList({ plugins }: RobotListProps) {
|
||||
const [selectedRobot, setSelectedRobot] = useState<RobotPlugin | null>(plugins[0] ?? null);
|
||||
|
||||
if (!plugins.length) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-24rem)] items-center justify-center">
|
||||
<p className="text-muted-foreground">No robots available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-[calc(100vh-24rem)] grid-cols-[400px_1fr] gap-8">
|
||||
{/* Left Pane - Robot List */}
|
||||
<div className="overflow-y-auto rounded-lg pr-4">
|
||||
<div className="space-y-3">
|
||||
{plugins.map((plugin) => (
|
||||
<RobotListItem
|
||||
key={plugin.robotId}
|
||||
plugin={plugin}
|
||||
isSelected={selectedRobot?.robotId === plugin.robotId}
|
||||
onSelect={() => setSelectedRobot(plugin)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Pane - Robot Details */}
|
||||
{selectedRobot && (
|
||||
<div className="overflow-y-auto rounded-lg border bg-card">
|
||||
<RobotHeader robot={selectedRobot} />
|
||||
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="specs">Specifications</TabsTrigger>
|
||||
<TabsTrigger value="actions">Actions</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-6">
|
||||
<RobotImages robot={selectedRobot} />
|
||||
<div className="rounded-lg border bg-card/50 p-4 shadow-sm">
|
||||
<h4 className="mb-4 font-medium">Documentation</h4>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<a
|
||||
href={selectedRobot.documentation.mainUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
User Manual
|
||||
</a>
|
||||
{selectedRobot.documentation.apiReference && (
|
||||
<a
|
||||
href={selectedRobot.documentation.apiReference}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
API Reference
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="specs" className="mt-6">
|
||||
<RobotSpecs robot={selectedRobot} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="actions" className="mt-6">
|
||||
<RobotActions robot={selectedRobot} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card-level-1))] shadow-sm",
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,22 +30,25 @@ const CardHeader = React.forwardRef<
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
|
||||
@@ -358,7 +358,7 @@ const SidebarHeader = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -373,7 +373,7 @@ const SidebarFooter = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
48
src/env.js
48
src/env.js
@@ -1,48 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
AUTH_SECRET:
|
||||
process.env.NODE_ENV === "production"
|
||||
? z.string()
|
||||
: z.string().optional(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here. This way you can ensure the app
|
||||
* isn't built with invalid env vars. To expose them to the client, prefix them with
|
||||
* `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
// NEXT_PUBLIC_CLIENTVAR: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
* You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
|
||||
* middlewares) or client-side so we need to destruct manually.
|
||||
*/
|
||||
runtimeEnv: {
|
||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
},
|
||||
/**
|
||||
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
|
||||
* useful for Docker builds.
|
||||
*/
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
/**
|
||||
* Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
|
||||
* `SOME_VAR=''` will throw an error.
|
||||
*/
|
||||
emptyStringAsUndefined: true,
|
||||
});
|
||||
50
src/env.mjs
50
src/env.mjs
@@ -1,16 +1,50 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
// Node environment
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
|
||||
// Database configuration
|
||||
DATABASE_URL: z.string().url(),
|
||||
STORAGE_TYPE: z.enum(["s3", "minio", "local"]).default("minio"),
|
||||
// ... other server-side env vars
|
||||
|
||||
// Authentication
|
||||
NEXTAUTH_SECRET: z.string().min(1),
|
||||
NEXTAUTH_URL: z.string().url(),
|
||||
|
||||
// Email configuration
|
||||
SMTP_HOST: z.string(),
|
||||
SMTP_PORT: z.string().transform(Number),
|
||||
SMTP_USER: z.string(),
|
||||
SMTP_PASS: z.string(),
|
||||
EMAIL_FROM_NAME: z.string(),
|
||||
EMAIL_FROM_ADDRESS: z.string().email(),
|
||||
},
|
||||
|
||||
client: {
|
||||
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||
// ... client-side env vars
|
||||
// Add client-side env vars here if needed
|
||||
},
|
||||
|
||||
runtimeEnv: {
|
||||
// Node environment
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
|
||||
// Database configuration
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
STORAGE_TYPE: process.env.STORAGE_TYPE,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL
|
||||
}
|
||||
})
|
||||
|
||||
// Authentication
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
|
||||
// Email configuration
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_PASS: process.env.SMTP_PASS,
|
||||
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
|
||||
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
|
||||
},
|
||||
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
});
|
||||
35
src/env.ts
35
src/env.ts
@@ -1,35 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
DATABASE_URL: z.string().url(),
|
||||
NEXTAUTH_SECRET: z.string().min(1),
|
||||
NEXTAUTH_URL: z.string().url(),
|
||||
// Email configuration
|
||||
SMTP_HOST: z.string(),
|
||||
SMTP_PORT: z.string().transform(Number),
|
||||
SMTP_USER: z.string(),
|
||||
SMTP_PASS: z.string(),
|
||||
EMAIL_FROM_NAME: z.string(),
|
||||
EMAIL_FROM_ADDRESS: z.string().email(),
|
||||
},
|
||||
client: {
|
||||
// Add client-side env vars here
|
||||
},
|
||||
runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
// Email configuration
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_PASS: process.env.SMTP_PASS,
|
||||
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
|
||||
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
|
||||
},
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
});
|
||||
159
src/lib/experiments/plugin-actions.tsx
Normal file
159
src/lib/experiments/plugin-actions.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { type RobotPlugin } from "~/lib/plugin-store/types";
|
||||
import {
|
||||
Move,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
KeyboardIcon,
|
||||
Pointer,
|
||||
Video,
|
||||
GitBranch,
|
||||
Repeat,
|
||||
Navigation,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// Map of action types to their icons
|
||||
const ACTION_ICONS: Record<string, LucideIcon> = {
|
||||
move: Move,
|
||||
speak: MessageSquare,
|
||||
wait: Clock,
|
||||
input: KeyboardIcon,
|
||||
gesture: Pointer,
|
||||
record: Video,
|
||||
condition: GitBranch,
|
||||
loop: Repeat,
|
||||
navigation: Navigation,
|
||||
};
|
||||
|
||||
export interface ActionConfig {
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
defaultParameters: Record<string, any>;
|
||||
pluginId?: string;
|
||||
ros2Config?: {
|
||||
messageType: string;
|
||||
topic?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
payloadMapping: {
|
||||
type: "direct" | "transform";
|
||||
transformFn?: string;
|
||||
};
|
||||
qos?: {
|
||||
reliability: "reliable" | "best_effort";
|
||||
durability: "volatile" | "transient_local";
|
||||
history: "keep_last" | "keep_all";
|
||||
depth?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function getActionIcon(iconName: string): ReactNode {
|
||||
const Icon = ACTION_ICONS[iconName.toLowerCase()] ?? Move;
|
||||
return <Icon className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export function getDefaultParameters(parameters: {
|
||||
type: "object";
|
||||
properties: Record<string, {
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
enum?: string[];
|
||||
unit?: string;
|
||||
}>;
|
||||
required: string[];
|
||||
}): Record<string, any> {
|
||||
const defaults: Record<string, any> = {};
|
||||
|
||||
for (const [key, prop] of Object.entries(parameters.properties)) {
|
||||
defaults[key] = prop.default ?? (
|
||||
prop.type === "number" ? 0 :
|
||||
prop.type === "string" ? "" :
|
||||
prop.type === "boolean" ? false :
|
||||
prop.type === "array" ? [] :
|
||||
prop.type === "object" ? {} :
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function getPluginActions(plugins: RobotPlugin[]): ActionConfig[] {
|
||||
return plugins.flatMap(plugin =>
|
||||
plugin.actions.map(action => ({
|
||||
type: `${plugin.robotId}:${action.type}`,
|
||||
title: action.title,
|
||||
description: action.description,
|
||||
icon: getActionIcon(action.icon ?? action.type),
|
||||
defaultParameters: getDefaultParameters(action.parameters),
|
||||
pluginId: plugin.robotId,
|
||||
ros2Config: action.ros2,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Built-in actions that are always available
|
||||
export const BUILT_IN_ACTIONS: ActionConfig[] = [
|
||||
{
|
||||
type: "wait",
|
||||
title: "Wait",
|
||||
description: "Pause for a specified duration",
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
defaultParameters: {
|
||||
duration: 1000,
|
||||
showCountdown: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
title: "User Input",
|
||||
description: "Wait for participant response",
|
||||
icon: <KeyboardIcon className="h-4 w-4" />,
|
||||
defaultParameters: {
|
||||
type: "button",
|
||||
prompt: "Please respond",
|
||||
timeout: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "record",
|
||||
title: "Record",
|
||||
description: "Start or stop recording",
|
||||
icon: <Video className="h-4 w-4" />,
|
||||
defaultParameters: {
|
||||
type: "start",
|
||||
streams: ["video"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "condition",
|
||||
title: "Condition",
|
||||
description: "Branch based on a condition",
|
||||
icon: <GitBranch className="h-4 w-4" />,
|
||||
defaultParameters: {
|
||||
condition: "",
|
||||
trueActions: [],
|
||||
falseActions: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "loop",
|
||||
title: "Loop",
|
||||
description: "Repeat a sequence of actions",
|
||||
icon: <Repeat className="h-4 w-4" />,
|
||||
defaultParameters: {
|
||||
count: 1,
|
||||
actions: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
156
src/lib/plugin-store/plugins/turtlebot3-burger.json
Normal file
156
src/lib/plugin-store/plugins/turtlebot3-burger.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"robotId": "turtlebot3-burger",
|
||||
"name": "TurtleBot3 Burger",
|
||||
"description": "A compact, affordable, programmable, ROS2-based mobile robot for education and research",
|
||||
"platform": "ROS2",
|
||||
"version": "2.0.0",
|
||||
|
||||
"manufacturer": {
|
||||
"name": "ROBOTIS",
|
||||
"website": "https://www.robotis.com/",
|
||||
"support": "https://emanual.robotis.com/docs/en/platform/turtlebot3/overview/"
|
||||
},
|
||||
|
||||
"documentation": {
|
||||
"mainUrl": "https://emanual.robotis.com/docs/en/platform/turtlebot3/overview/",
|
||||
"apiReference": "https://emanual.robotis.com/docs/en/platform/turtlebot3/ros2_manipulation/",
|
||||
"wikiUrl": "https://wiki.ros.org/turtlebot3",
|
||||
"videoUrl": "https://www.youtube.com/watch?v=rVM994ZhsEM"
|
||||
},
|
||||
|
||||
"assets": {
|
||||
"thumbnailUrl": "/robots/turtlebot3-burger-thumb.png",
|
||||
"images": {
|
||||
"main": "/robots/turtlebot3-burger-main.png",
|
||||
"angles": {
|
||||
"front": "/robots/turtlebot3-burger-front.png",
|
||||
"side": "/robots/turtlebot3-burger-side.png",
|
||||
"top": "/robots/turtlebot3-burger-top.png"
|
||||
},
|
||||
"dimensions": "/robots/turtlebot3-burger-dimensions.png"
|
||||
},
|
||||
"model": {
|
||||
"format": "URDF",
|
||||
"url": "https://raw.githubusercontent.com/ROBOTIS-GIT/turtlebot3/master/turtlebot3_description/urdf/turtlebot3_burger.urdf"
|
||||
}
|
||||
},
|
||||
|
||||
"specs": {
|
||||
"dimensions": {
|
||||
"length": 0.138,
|
||||
"width": 0.178,
|
||||
"height": 0.192,
|
||||
"weight": 1.0
|
||||
},
|
||||
"capabilities": [
|
||||
"differential_drive",
|
||||
"lidar",
|
||||
"imu",
|
||||
"odometry"
|
||||
],
|
||||
"maxSpeed": 0.22,
|
||||
"batteryLife": 2.5
|
||||
},
|
||||
|
||||
"ros2Config": {
|
||||
"namespace": "turtlebot3",
|
||||
"nodePrefix": "hri_studio",
|
||||
"defaultTopics": {
|
||||
"cmd_vel": "/cmd_vel",
|
||||
"odom": "/odom",
|
||||
"scan": "/scan",
|
||||
"imu": "/imu",
|
||||
"joint_states": "/joint_states"
|
||||
}
|
||||
},
|
||||
|
||||
"actions": [
|
||||
{
|
||||
"actionId": "move-velocity",
|
||||
"type": "move",
|
||||
"title": "Set Velocity",
|
||||
"description": "Control the robot's linear and angular velocity",
|
||||
"icon": "navigation",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"linear": {
|
||||
"type": "number",
|
||||
"title": "Linear Velocity",
|
||||
"description": "Forward/backward velocity",
|
||||
"default": 0,
|
||||
"minimum": -0.22,
|
||||
"maximum": 0.22,
|
||||
"unit": "m/s"
|
||||
},
|
||||
"angular": {
|
||||
"type": "number",
|
||||
"title": "Angular Velocity",
|
||||
"description": "Rotational velocity",
|
||||
"default": 0,
|
||||
"minimum": -2.84,
|
||||
"maximum": 2.84,
|
||||
"unit": "rad/s"
|
||||
}
|
||||
},
|
||||
"required": ["linear", "angular"]
|
||||
},
|
||||
"ros2": {
|
||||
"messageType": "geometry_msgs/msg/Twist",
|
||||
"topic": "/cmd_vel",
|
||||
"payloadMapping": {
|
||||
"type": "transform",
|
||||
"transformFn": "transformToTwist"
|
||||
},
|
||||
"qos": {
|
||||
"reliability": "reliable",
|
||||
"durability": "volatile",
|
||||
"history": "keep_last",
|
||||
"depth": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"actionId": "move-to-pose",
|
||||
"type": "move",
|
||||
"title": "Move to Position",
|
||||
"description": "Navigate to a specific position on the map",
|
||||
"icon": "target",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"title": "X Position",
|
||||
"description": "X coordinate in meters",
|
||||
"default": 0,
|
||||
"unit": "m"
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"title": "Y Position",
|
||||
"description": "Y coordinate in meters",
|
||||
"default": 0,
|
||||
"unit": "m"
|
||||
},
|
||||
"theta": {
|
||||
"type": "number",
|
||||
"title": "Orientation",
|
||||
"description": "Final orientation",
|
||||
"default": 0,
|
||||
"unit": "rad"
|
||||
}
|
||||
},
|
||||
"required": ["x", "y", "theta"]
|
||||
},
|
||||
"ros2": {
|
||||
"messageType": "geometry_msgs/msg/PoseStamped",
|
||||
"action": "/navigate_to_pose",
|
||||
"payloadMapping": {
|
||||
"type": "transform",
|
||||
"transformFn": "transformToPoseStamped"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
153
src/lib/plugin-store/service.ts
Normal file
153
src/lib/plugin-store/service.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { db } from "~/server/db";
|
||||
import { pluginRepositories } from "~/server/db/schema";
|
||||
import {
|
||||
type RobotPlugin,
|
||||
type RepositoryMetadata,
|
||||
repositoryMetadataSchema,
|
||||
} from "./types";
|
||||
import { PluginStore } from "./store";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// Singleton instance
|
||||
let store: PluginStore | null = null;
|
||||
|
||||
export async function getPluginStore() {
|
||||
if (!store) {
|
||||
store = new PluginStore();
|
||||
try {
|
||||
await store.initialize();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize plugin store:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function getPlugins(): Promise<RobotPlugin[]> {
|
||||
const store = await getPluginStore();
|
||||
return store.getAllPlugins();
|
||||
}
|
||||
|
||||
export async function getRepositories(): Promise<RepositoryMetadata[]> {
|
||||
const store = await getPluginStore();
|
||||
return store.getAllRepositories();
|
||||
}
|
||||
|
||||
export async function addRepository(url: string): Promise<RepositoryMetadata> {
|
||||
// Clean URL and ensure it ends with a trailing slash
|
||||
const cleanUrl = url.trim().replace(/\/?$/, "/");
|
||||
|
||||
try {
|
||||
// Determine if this is a Git URL or repository URL
|
||||
const isGitUrl = cleanUrl.includes("github.com/");
|
||||
const repoUrl = isGitUrl
|
||||
? cleanUrl
|
||||
.replace("github.com/", "")
|
||||
.split("/")
|
||||
.slice(0, 2)
|
||||
.join("/")
|
||||
.replace(/\/$/, "")
|
||||
: cleanUrl.replace(/\/$/, "");
|
||||
|
||||
// Construct URLs
|
||||
const gitUrl = isGitUrl
|
||||
? cleanUrl
|
||||
: `https://github.com/${repoUrl.replace("https://", "").replace(".github.io/", "/")}`;
|
||||
const repositoryUrl = isGitUrl
|
||||
? `https://${repoUrl.replace(/^[^/]+\//, "").replace(/\/$/, "")}.github.io/${repoUrl.split("/").pop()}`
|
||||
: cleanUrl;
|
||||
|
||||
// Fetch repository metadata
|
||||
const metadataUrl = `${repositoryUrl}/repository.json`;
|
||||
console.log("Loading repository metadata from:", metadataUrl);
|
||||
|
||||
const response = await fetch(metadataUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch repository metadata (${response.status}): ${response.statusText}\n` +
|
||||
"Make sure the URL points to a valid plugin repository containing repository.json",
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
throw new Error("Empty response from repository");
|
||||
}
|
||||
|
||||
console.log("Repository metadata content:", text);
|
||||
const metadata = JSON.parse(text);
|
||||
|
||||
// Validate metadata
|
||||
const validatedMetadata = await repositoryMetadataSchema.parseAsync({
|
||||
...metadata,
|
||||
urls: {
|
||||
git: gitUrl,
|
||||
repository: repositoryUrl,
|
||||
},
|
||||
enabled: true,
|
||||
lastSyncedAt: new Date(),
|
||||
});
|
||||
|
||||
// Check if repository already exists
|
||||
const existing = await db.query.pluginRepositories.findFirst({
|
||||
where: eq(pluginRepositories.id, validatedMetadata.id),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Repository ${validatedMetadata.id} already exists`);
|
||||
}
|
||||
|
||||
// Add to database
|
||||
const [stored] = await db
|
||||
.insert(pluginRepositories)
|
||||
.values({
|
||||
id: validatedMetadata.id,
|
||||
urls: {
|
||||
git: gitUrl,
|
||||
repository: repositoryUrl,
|
||||
},
|
||||
trust: validatedMetadata.trust,
|
||||
enabled: true,
|
||||
lastSyncedAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Clear the store instance to force a fresh load
|
||||
store = null;
|
||||
|
||||
return validatedMetadata;
|
||||
} catch (error) {
|
||||
console.error("Failed to add repository:", error);
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Failed to add repository");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRepository(id: string): Promise<void> {
|
||||
if (!id) {
|
||||
throw new Error("Repository ID is required");
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove from database first
|
||||
await db.delete(pluginRepositories).where(eq(pluginRepositories.id, id));
|
||||
|
||||
// Clear the store instance to force a fresh load
|
||||
store = null;
|
||||
} catch (error) {
|
||||
console.error("Failed to remove repository:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlugin(
|
||||
robotId: string,
|
||||
): Promise<RobotPlugin | undefined> {
|
||||
const store = await getPluginStore();
|
||||
return store.getPlugin(robotId);
|
||||
}
|
||||
468
src/lib/plugin-store/store.ts
Normal file
468
src/lib/plugin-store/store.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import { z } from "zod";
|
||||
import { type RobotPlugin, type RepositoryMetadata, type StoredRepositoryMetadata, robotPluginSchema, repositoryMetadataSchema, storedRepositoryMetadataSchema } from "./types";
|
||||
import { db } from "~/server/db";
|
||||
import { pluginRepositories } from "~/server/db/schema/store";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export class PluginLoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public robotId?: string,
|
||||
public cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PluginLoadError";
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginStore {
|
||||
private plugins: Map<string, RobotPlugin> = new Map();
|
||||
private repositories: Map<string, RepositoryMetadata> = new Map();
|
||||
private transformFunctions: Map<string, Function> = new Map();
|
||||
private pluginToRepo: Map<string, string> = new Map(); // Maps plugin IDs to repository IDs
|
||||
private lastRefresh: Map<string, number> = new Map(); // Cache timestamps
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor() {
|
||||
// Register built-in transform functions
|
||||
this.registerTransformFunction("transformToTwist", this.transformToTwist);
|
||||
this.registerTransformFunction("transformToPoseStamped", this.transformToPoseStamped);
|
||||
}
|
||||
|
||||
private getRepositoryFileUrl(baseUrl: string, filePath: string): string {
|
||||
try {
|
||||
// Clean URLs and join them
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
||||
const cleanFilePath = filePath.replace(/^\//, '');
|
||||
return `${cleanBaseUrl}/${cleanFilePath}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to construct repository file URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Load repositories from database
|
||||
const dbRepositories = await db.query.pluginRepositories.findMany();
|
||||
|
||||
for (const repository of dbRepositories) {
|
||||
if (!repository.enabled) continue;
|
||||
|
||||
// Convert database model to repository metadata
|
||||
const storedMetadata: StoredRepositoryMetadata = {
|
||||
id: repository.id,
|
||||
url: repository.url,
|
||||
trust: repository.trust as "official" | "verified" | "community",
|
||||
enabled: repository.enabled,
|
||||
lastSyncedAt: repository.lastSyncedAt ?? undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
// Fetch full metadata from repository
|
||||
const metadata = await this.refreshRepositoryMetadata(storedMetadata);
|
||||
|
||||
// Add to in-memory cache
|
||||
this.repositories.set(repository.id, metadata);
|
||||
|
||||
// Always load plugins on initialization
|
||||
await this.loadRepositoryPlugins(metadata);
|
||||
this.lastRefresh.set(repository.id, Date.now());
|
||||
|
||||
// Update last synced timestamp
|
||||
await db.update(pluginRepositories)
|
||||
.set({
|
||||
lastSyncedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pluginRepositories.id, repository.id));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to refresh repository metadata for ${repository.id}:`, error);
|
||||
// Continue with next repository if refresh fails
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize plugin store:", error);
|
||||
throw new PluginLoadError(
|
||||
"Failed to initialize plugin store",
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRefreshCache(repositoryId: string): boolean {
|
||||
const lastRefreshTime = this.lastRefresh.get(repositoryId);
|
||||
if (!lastRefreshTime) return true;
|
||||
return Date.now() - lastRefreshTime > this.CACHE_TTL;
|
||||
}
|
||||
|
||||
private async refreshRepositoryMetadata(repository: StoredRepositoryMetadata): Promise<RepositoryMetadata> {
|
||||
try {
|
||||
const repoUrl = this.getRepositoryFileUrl(repository.url, "repository.json");
|
||||
const response = await fetch(repoUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch repository metadata: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
throw new Error("Empty response from repository");
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
const metadata = await repositoryMetadataSchema.parseAsync({
|
||||
...data,
|
||||
id: repository.id,
|
||||
enabled: repository.enabled,
|
||||
lastSyncedAt: repository.lastSyncedAt,
|
||||
});
|
||||
|
||||
// Transform asset URLs to absolute URLs
|
||||
if (metadata.assets) {
|
||||
metadata.assets = {
|
||||
icon: metadata.assets.icon ? this.getRepositoryFileUrl(repository.url, metadata.assets.icon) : undefined,
|
||||
logo: metadata.assets.logo ? this.getRepositoryFileUrl(repository.url, metadata.assets.logo) : undefined,
|
||||
banner: metadata.assets.banner ? this.getRepositoryFileUrl(repository.url, metadata.assets.banner) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize stats with default values
|
||||
metadata.stats = {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
plugins: 0,
|
||||
...metadata.stats,
|
||||
};
|
||||
|
||||
// Update in-memory cache
|
||||
this.repositories.set(repository.id, metadata);
|
||||
this.lastRefresh.set(repository.id, Date.now());
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh repository metadata for ${repository.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loadRepository(url: string): Promise<RepositoryMetadata> {
|
||||
try {
|
||||
// Fetch repository metadata
|
||||
const repoUrl = this.getRepositoryFileUrl(url, "repository.json");
|
||||
console.log("Loading repository metadata from:", repoUrl);
|
||||
const response = await fetch(repoUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch repository metadata: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
console.log("Repository metadata content:", text);
|
||||
|
||||
if (!text) {
|
||||
throw new Error("Empty response from repository");
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
console.log("Parsed repository metadata:", data);
|
||||
const metadata = await repositoryMetadataSchema.parseAsync({
|
||||
...data,
|
||||
enabled: true,
|
||||
lastSyncedAt: new Date(),
|
||||
});
|
||||
|
||||
// Transform asset URLs to absolute URLs
|
||||
if (metadata.assets) {
|
||||
metadata.assets = {
|
||||
icon: metadata.assets.icon ? this.getRepositoryFileUrl(url, metadata.assets.icon) : undefined,
|
||||
logo: metadata.assets.logo ? this.getRepositoryFileUrl(url, metadata.assets.logo) : undefined,
|
||||
banner: metadata.assets.banner ? this.getRepositoryFileUrl(url, metadata.assets.banner) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize stats with default values
|
||||
metadata.stats = {
|
||||
downloads: 0,
|
||||
stars: 0,
|
||||
plugins: 0,
|
||||
...metadata.stats,
|
||||
};
|
||||
|
||||
// Check if repository already exists
|
||||
const existing = await db.query.pluginRepositories.findFirst({
|
||||
where: eq(pluginRepositories.id, metadata.id),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Repository ${metadata.id} already exists`);
|
||||
}
|
||||
|
||||
// Add to database - only store essential fields
|
||||
const storedMetadata: StoredRepositoryMetadata = {
|
||||
id: metadata.id,
|
||||
url: metadata.url,
|
||||
trust: metadata.trust,
|
||||
enabled: true,
|
||||
lastSyncedAt: new Date(),
|
||||
};
|
||||
|
||||
await db.insert(pluginRepositories).values({
|
||||
...storedMetadata,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Add to in-memory cache
|
||||
this.repositories.set(metadata.id, metadata);
|
||||
this.lastRefresh.set(metadata.id, Date.now());
|
||||
|
||||
// Load plugins
|
||||
await this.loadRepositoryPlugins(metadata);
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.error("Failed to load repository:", error);
|
||||
throw new PluginLoadError(
|
||||
`Failed to load repository from ${url}`,
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private transformAssetUrls(plugin: RobotPlugin, baseUrl: string): RobotPlugin {
|
||||
const transformUrl = (url: string) => {
|
||||
if (url.startsWith('http')) return url;
|
||||
return this.getRepositoryFileUrl(baseUrl, url);
|
||||
};
|
||||
|
||||
return {
|
||||
...plugin,
|
||||
assets: {
|
||||
...plugin.assets,
|
||||
thumbnailUrl: transformUrl(plugin.assets.thumbnailUrl),
|
||||
images: {
|
||||
...plugin.assets.images,
|
||||
main: transformUrl(plugin.assets.images.main),
|
||||
angles: plugin.assets.images.angles ? {
|
||||
front: plugin.assets.images.angles.front ? transformUrl(plugin.assets.images.angles.front) : undefined,
|
||||
side: plugin.assets.images.angles.side ? transformUrl(plugin.assets.images.angles.side) : undefined,
|
||||
top: plugin.assets.images.angles.top ? transformUrl(plugin.assets.images.angles.top) : undefined,
|
||||
} : undefined,
|
||||
dimensions: plugin.assets.images.dimensions ? transformUrl(plugin.assets.images.dimensions) : undefined,
|
||||
},
|
||||
model: plugin.assets.model ? {
|
||||
...plugin.assets.model,
|
||||
url: transformUrl(plugin.assets.model.url),
|
||||
} : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadRepositoryPlugins(repository: RepositoryMetadata) {
|
||||
try {
|
||||
// Load plugins index
|
||||
const indexUrl = this.getRepositoryFileUrl(repository.url, "plugins/index.json");
|
||||
console.log("Loading plugins index from:", indexUrl);
|
||||
const indexResponse = await fetch(indexUrl);
|
||||
|
||||
if (!indexResponse.ok) {
|
||||
throw new Error(`Failed to fetch plugins index (${indexResponse.status})`);
|
||||
}
|
||||
|
||||
const indexText = await indexResponse.text();
|
||||
console.log("Plugins index content:", indexText);
|
||||
|
||||
if (!indexText || indexText.trim() === "") {
|
||||
throw new Error("Empty response from plugins index");
|
||||
}
|
||||
|
||||
const pluginFiles = JSON.parse(indexText) as string[];
|
||||
console.log("Found plugin files:", pluginFiles);
|
||||
|
||||
// Update plugin count in repository stats
|
||||
if (repository.stats) {
|
||||
repository.stats.plugins = pluginFiles.length;
|
||||
// Update in-memory cache only
|
||||
this.repositories.set(repository.id, repository);
|
||||
}
|
||||
|
||||
// Load each plugin file
|
||||
for (const pluginFile of pluginFiles) {
|
||||
try {
|
||||
const pluginUrl = this.getRepositoryFileUrl(repository.url, `plugins/${pluginFile}`);
|
||||
console.log("Loading plugin from:", pluginUrl);
|
||||
const pluginResponse = await fetch(pluginUrl);
|
||||
|
||||
if (!pluginResponse.ok) {
|
||||
console.error(`Failed to load plugin file ${pluginFile}: ${pluginResponse.statusText}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginText = await pluginResponse.text();
|
||||
if (!pluginText || pluginText.trim() === "") {
|
||||
console.error(`Empty response from plugin file ${pluginFile}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginData = JSON.parse(pluginText);
|
||||
const plugin = await robotPluginSchema.parseAsync(pluginData);
|
||||
|
||||
// Transform relative asset URLs to absolute URLs
|
||||
const transformedPlugin = this.transformAssetUrls(plugin, repository.url);
|
||||
|
||||
// Store the plugin and its repository mapping
|
||||
this.plugins.set(transformedPlugin.robotId, transformedPlugin);
|
||||
this.pluginToRepo.set(transformedPlugin.robotId, repository.id);
|
||||
|
||||
console.log(`Successfully loaded plugin: ${transformedPlugin.name} (${transformedPlugin.robotId})`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load plugin ${pluginFile}:`, error);
|
||||
// Continue with next plugin if one fails
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load plugins for repository ${repository.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async removeRepository(id: string): Promise<void> {
|
||||
const repository = this.repositories.get(id);
|
||||
if (!repository) return;
|
||||
|
||||
if (repository.official) {
|
||||
throw new Error("Cannot remove official repository");
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await db.delete(pluginRepositories).where(eq(pluginRepositories.id, id));
|
||||
|
||||
// Remove plugins associated with this repository
|
||||
for (const [pluginId, repoId] of this.pluginToRepo.entries()) {
|
||||
if (repoId === id) {
|
||||
this.plugins.delete(pluginId);
|
||||
this.pluginToRepo.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from cache
|
||||
this.repositories.delete(id);
|
||||
this.lastRefresh.delete(id);
|
||||
}
|
||||
|
||||
async loadPluginFromUrl(url: string): Promise<RobotPlugin> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch plugin: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
throw new Error("Empty response from plugin URL");
|
||||
}
|
||||
|
||||
return this.loadPluginFromJson(text);
|
||||
} catch (error) {
|
||||
throw new PluginLoadError(
|
||||
`Failed to load plugin from URL: ${url}`,
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPluginFromJson(jsonString: string): Promise<RobotPlugin> {
|
||||
try {
|
||||
const data = JSON.parse(jsonString);
|
||||
const plugin = await robotPluginSchema.parseAsync(data);
|
||||
this.plugins.set(plugin.robotId, plugin);
|
||||
return plugin;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new PluginLoadError(
|
||||
`Invalid plugin format: ${error.errors.map(e => e.message).join(", ")}`,
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
throw new PluginLoadError(
|
||||
"Failed to load plugin",
|
||||
undefined,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getPlugin(robotId: string): RobotPlugin | undefined {
|
||||
return this.plugins.get(robotId);
|
||||
}
|
||||
|
||||
getAllPlugins(): RobotPlugin[] {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
getRepository(id: string): RepositoryMetadata | undefined {
|
||||
return this.repositories.get(id);
|
||||
}
|
||||
|
||||
getAllRepositories(): RepositoryMetadata[] {
|
||||
return Array.from(this.repositories.values());
|
||||
}
|
||||
|
||||
registerTransformFunction(name: string, fn: Function): void {
|
||||
this.transformFunctions.set(name, fn);
|
||||
}
|
||||
|
||||
getTransformFunction(name: string): Function | undefined {
|
||||
return this.transformFunctions.get(name);
|
||||
}
|
||||
|
||||
private async validatePlugin(data: unknown): Promise<RobotPlugin> {
|
||||
return robotPluginSchema.parseAsync(data);
|
||||
}
|
||||
|
||||
private transformToTwist(params: { linear: number; angular: number }) {
|
||||
return {
|
||||
linear: {
|
||||
x: params.linear,
|
||||
y: 0.0,
|
||||
z: 0.0
|
||||
},
|
||||
angular: {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: params.angular
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private transformToPoseStamped(params: { x: number; y: number; theta: number }) {
|
||||
return {
|
||||
header: {
|
||||
stamp: {
|
||||
sec: Math.floor(Date.now() / 1000),
|
||||
nanosec: (Date.now() % 1000) * 1000000
|
||||
},
|
||||
frame_id: "map"
|
||||
},
|
||||
pose: {
|
||||
position: {
|
||||
x: params.x,
|
||||
y: params.y,
|
||||
z: 0.0
|
||||
},
|
||||
orientation: {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: Math.sin(params.theta / 2),
|
||||
w: Math.cos(params.theta / 2)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
221
src/lib/plugin-store/types.ts
Normal file
221
src/lib/plugin-store/types.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Version compatibility schema
|
||||
export const versionCompatibilitySchema = z.object({
|
||||
hristudio: z.object({
|
||||
min: z.string(),
|
||||
max: z.string().optional(),
|
||||
recommended: z.string().optional(),
|
||||
}),
|
||||
ros2: z
|
||||
.object({
|
||||
distributions: z.array(z.string()),
|
||||
recommended: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Repository metadata schema
|
||||
export const storedRepositoryMetadataSchema = z.object({
|
||||
id: z.string(),
|
||||
urls: z.object({
|
||||
git: z.string().url().optional(),
|
||||
repository: z.string().url(),
|
||||
}),
|
||||
trust: z.enum(["official", "verified", "community"]).default("community"),
|
||||
enabled: z.boolean().default(true),
|
||||
lastSyncedAt: z.date().optional(),
|
||||
});
|
||||
|
||||
export const repositoryMetadataSchema = storedRepositoryMetadataSchema.extend({
|
||||
// These fields are fetched from the repository.json but not stored in the database
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
official: z.boolean().default(false),
|
||||
author: z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
url: z.string().url().optional(),
|
||||
organization: z.string().optional(),
|
||||
}),
|
||||
maintainers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
url: z.string().url().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
homepage: z.string().url().optional(),
|
||||
license: z.string(),
|
||||
defaultBranch: z.string().default("main"),
|
||||
lastUpdated: z.string().datetime(),
|
||||
compatibility: z.object({
|
||||
hristudio: z.object({
|
||||
min: z.string(),
|
||||
max: z.string().optional(),
|
||||
recommended: z.string().optional(),
|
||||
}),
|
||||
ros2: z
|
||||
.object({
|
||||
distributions: z.array(z.string()),
|
||||
recommended: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
tags: z.array(z.string()).default([]),
|
||||
stats: z
|
||||
.object({
|
||||
plugins: z.number().default(0),
|
||||
})
|
||||
.optional(),
|
||||
assets: z
|
||||
.object({
|
||||
icon: z.string().optional(),
|
||||
logo: z.string().optional(),
|
||||
banner: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type StoredRepositoryMetadata = z.infer<
|
||||
typeof storedRepositoryMetadataSchema
|
||||
>;
|
||||
export type RepositoryMetadata = z.infer<typeof repositoryMetadataSchema>;
|
||||
|
||||
// Core types for the plugin store
|
||||
export type ActionType =
|
||||
| "move" // Robot movement
|
||||
| "speak" // Robot speech
|
||||
| "wait" // Wait for a duration
|
||||
| "input" // Wait for user input
|
||||
| "gesture" // Robot gesture
|
||||
| "record" // Start/stop recording
|
||||
| "condition" // Conditional branching
|
||||
| "loop"; // Repeat actions
|
||||
|
||||
// Zod schema for parameter properties
|
||||
export const parameterPropertySchema = z.object({
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
default: z.any().optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
enum: z.array(z.string()).optional(),
|
||||
unit: z.string().optional(),
|
||||
});
|
||||
|
||||
// Zod schema for ROS2 QoS settings
|
||||
export const qosSchema = z.object({
|
||||
reliability: z.enum(["reliable", "best_effort"]),
|
||||
durability: z.enum(["volatile", "transient_local"]),
|
||||
history: z.enum(["keep_last", "keep_all"]),
|
||||
depth: z.number().optional(),
|
||||
});
|
||||
|
||||
// Zod schema for action definition
|
||||
export const actionDefinitionSchema = z.object({
|
||||
actionId: z.string(),
|
||||
type: z.enum([
|
||||
"move",
|
||||
"speak",
|
||||
"wait",
|
||||
"input",
|
||||
"gesture",
|
||||
"record",
|
||||
"condition",
|
||||
"loop",
|
||||
]),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
icon: z.string().optional(),
|
||||
parameters: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(parameterPropertySchema),
|
||||
required: z.array(z.string()),
|
||||
}),
|
||||
ros2: z.object({
|
||||
messageType: z.string(),
|
||||
topic: z.string().optional(),
|
||||
service: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
payloadMapping: z.object({
|
||||
type: z.enum(["direct", "transform"]),
|
||||
map: z.record(z.string()).optional(),
|
||||
transformFn: z.string().optional(),
|
||||
}),
|
||||
qos: qosSchema.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Zod schema for the entire robot plugin
|
||||
export const robotPluginSchema = z.object({
|
||||
robotId: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
platform: z.string(),
|
||||
version: z.string(),
|
||||
|
||||
manufacturer: z.object({
|
||||
name: z.string(),
|
||||
website: z.string().url().optional(),
|
||||
support: z.string().url().optional(),
|
||||
}),
|
||||
|
||||
documentation: z.object({
|
||||
mainUrl: z.string().url(),
|
||||
apiReference: z.string().url().optional(),
|
||||
wikiUrl: z.string().url().optional(),
|
||||
videoUrl: z.string().url().optional(),
|
||||
}),
|
||||
|
||||
assets: z.object({
|
||||
thumbnailUrl: z.string(),
|
||||
logo: z.string().optional(),
|
||||
images: z.object({
|
||||
main: z.string(),
|
||||
angles: z
|
||||
.object({
|
||||
front: z.string().optional(),
|
||||
side: z.string().optional(),
|
||||
top: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
dimensions: z.string().optional(),
|
||||
}),
|
||||
model: z
|
||||
.object({
|
||||
format: z.enum(["URDF", "glTF", "other"]),
|
||||
url: z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
|
||||
specs: z.object({
|
||||
dimensions: z.object({
|
||||
length: z.number(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
weight: z.number(),
|
||||
}),
|
||||
capabilities: z.array(z.string()),
|
||||
maxSpeed: z.number(),
|
||||
batteryLife: z.number(),
|
||||
}),
|
||||
|
||||
actions: z.array(actionDefinitionSchema),
|
||||
|
||||
ros2Config: z.object({
|
||||
namespace: z.string(),
|
||||
nodePrefix: z.string(),
|
||||
defaultTopics: z.record(z.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
// TypeScript types inferred from the Zod schemas
|
||||
export type ParameterProperty = z.infer<typeof parameterPropertySchema>;
|
||||
export type QoSSettings = z.infer<typeof qosSchema>;
|
||||
export type ActionDefinition = z.infer<typeof actionDefinitionSchema>;
|
||||
export type RobotPlugin = z.infer<typeof robotPluginSchema>;
|
||||
@@ -2,6 +2,7 @@ import { createTRPCRouter } from "~/server/api/trpc";
|
||||
import { studyRouter } from "./routers/study";
|
||||
import { participantRouter } from "./routers/participant";
|
||||
import { experimentRouter } from "./routers/experiment";
|
||||
import { pluginStoreRouter } from "./routers/plugin-store";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -12,6 +13,7 @@ export const appRouter = createTRPCRouter({
|
||||
study: studyRouter,
|
||||
participant: participantRouter,
|
||||
experiment: experimentRouter,
|
||||
pluginStore: pluginStoreRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
178
src/server/api/routers/plugin-store.ts
Normal file
178
src/server/api/routers/plugin-store.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import {
|
||||
addRepository,
|
||||
getPlugins,
|
||||
getRepositories,
|
||||
removeRepository,
|
||||
} from "~/lib/plugin-store/service";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { installedPlugins } from "~/server/db/schema/store";
|
||||
|
||||
export const pluginStoreRouter = createTRPCRouter({
|
||||
// Get all repositories
|
||||
getRepositories: protectedProcedure.query(async () => {
|
||||
try {
|
||||
return await getRepositories();
|
||||
} catch (error) {
|
||||
console.error("Failed to get repositories:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get repositories",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Get all available plugins
|
||||
getPlugins: protectedProcedure.query(async () => {
|
||||
try {
|
||||
return await getPlugins();
|
||||
} catch (error) {
|
||||
console.error("Failed to get plugins:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get plugins",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Add a new repository
|
||||
addRepository: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
url: z.string().url(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
return await addRepository(input.url);
|
||||
} catch (error) {
|
||||
console.error("Failed to add repository:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Failed to add repository",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Remove a repository
|
||||
removeRepository: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await removeRepository(input.id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to remove repository:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to remove repository",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Install a plugin
|
||||
installPlugin: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
robotId: z.string(),
|
||||
repositoryId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
// Get plugin details
|
||||
const plugin = await getPlugins().then((plugins) =>
|
||||
plugins.find((p) => p.robotId === input.robotId),
|
||||
);
|
||||
|
||||
if (!plugin) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Plugin not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already installed
|
||||
const existing = await ctx.db.query.installedPlugins.findFirst({
|
||||
where: eq(installedPlugins.robotId, input.robotId),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Plugin already installed",
|
||||
});
|
||||
}
|
||||
|
||||
// Install plugin
|
||||
const [installed] = await ctx.db
|
||||
.insert(installedPlugins)
|
||||
.values({
|
||||
robotId: input.robotId,
|
||||
repositoryId: input.repositoryId,
|
||||
enabled: true,
|
||||
config: {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return installed;
|
||||
} catch (error) {
|
||||
console.error("Failed to install plugin:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Failed to install plugin",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Uninstall a plugin
|
||||
uninstallPlugin: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
robotId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await ctx.db
|
||||
.delete(installedPlugins)
|
||||
.where(eq(installedPlugins.robotId, input.robotId));
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to uninstall plugin:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to uninstall plugin",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Get installed plugins
|
||||
getInstalledPlugins: protectedProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
return await ctx.db.query.installedPlugins.findMany({
|
||||
orderBy: (installedPlugins, { asc }) => [asc(installedPlugins.robotId)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to get installed plugins:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get installed plugins",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -73,6 +73,12 @@ export const authOptions: NextAuthOptions = {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.email = user.email;
|
||||
token.firstName = user.firstName;
|
||||
token.lastName = user.lastName;
|
||||
token.name =
|
||||
user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: null;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
@@ -80,6 +86,9 @@ export const authOptions: NextAuthOptions = {
|
||||
if (token) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.email = token.email as string;
|
||||
session.user.firstName = token.firstName as string | null;
|
||||
session.user.lastName = token.lastName as string | null;
|
||||
session.user.name = token.name as string | null;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { env } from "~/env.mjs";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
export * from "./schema/auth";
|
||||
export * from "./schema/studies";
|
||||
export * from "./schema/permissions";
|
||||
export * from "./schema/experiments";
|
||||
export * from "./schema/store";
|
||||
@@ -2,13 +2,12 @@ import { relations } from "drizzle-orm";
|
||||
import {
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
serial
|
||||
varchar
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { participants } from "../schema";
|
||||
import { createTable } from "../utils";
|
||||
import { users } from "./auth";
|
||||
import { studies } from "./studies";
|
||||
|
||||
@@ -40,7 +39,7 @@ export const trialStatusEnum = pgEnum("trial_status", [
|
||||
]);
|
||||
|
||||
// Tables
|
||||
export const experiments = pgTable("experiments", {
|
||||
export const experiments = createTable("experiments", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id")
|
||||
.notNull()
|
||||
@@ -59,7 +58,7 @@ export const experiments = pgTable("experiments", {
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const steps = pgTable("steps", {
|
||||
export const steps = createTable("steps", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
experimentId: integer("experiment_id")
|
||||
.notNull()
|
||||
@@ -74,7 +73,7 @@ export const steps = pgTable("steps", {
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const actions = pgTable("actions", {
|
||||
export const actions = createTable("actions", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
stepId: integer("step_id")
|
||||
.notNull()
|
||||
@@ -88,7 +87,7 @@ export const actions = pgTable("actions", {
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const trials = pgTable("trials", {
|
||||
export const trials = createTable("trials", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
experimentId: integer("experiment_id")
|
||||
.notNull()
|
||||
@@ -110,7 +109,7 @@ export const trials = pgTable("trials", {
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const trialEvents = pgTable("trial_events", {
|
||||
export const trialEvents = createTable("trial_events", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
trialId: integer("trial_id")
|
||||
.notNull()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./auth";
|
||||
export * from "./studies";
|
||||
export * from "./permissions";
|
||||
export * from "./experiments";
|
||||
@@ -56,11 +56,12 @@ export const userRoles = createTable(
|
||||
.notNull()
|
||||
.references(() => roles.id, { onDelete: "cascade" }),
|
||||
studyId: integer("study_id")
|
||||
.notNull()
|
||||
.references(() => studies.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.roleId, table.studyId ?? ""] }),
|
||||
pk: primaryKey({ columns: [table.userId, table.roleId, table.studyId] }),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
32
src/server/db/schema/store.ts
Normal file
32
src/server/db/schema/store.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { jsonb, text, timestamp, boolean } from "drizzle-orm/pg-core";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { createTable } from "../utils";
|
||||
|
||||
export const pluginRepositories = createTable("plugin_repositories", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
urls: jsonb("urls").notNull().$type<{ git: string; repository: string }>(),
|
||||
trust: text("trust", { enum: ["official", "verified", "community"] })
|
||||
.notNull()
|
||||
.default("community"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
lastSyncedAt: timestamp("last_synced_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const installedPlugins = createTable("installed_plugins", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createId()),
|
||||
robotId: text("robot_id").notNull(),
|
||||
repositoryId: text("repository_id")
|
||||
.notNull()
|
||||
.references(() => pluginRepositories.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
config: jsonb("config").notNull().default({}),
|
||||
lastSyncedAt: timestamp("last_synced_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -1,9 +1,8 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { integer, pgEnum, text, timestamp, varchar, serial, jsonb } from "drizzle-orm/pg-core";
|
||||
import { integer, pgEnum, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
import { ROLES } from "~/lib/permissions/constants";
|
||||
import { createTable } from "../utils";
|
||||
import { users } from "./auth";
|
||||
import { type Step } from "~/lib/experiments/types";
|
||||
|
||||
// Create enum from role values
|
||||
export const studyRoleEnum = pgEnum("study_role", [
|
||||
@@ -35,12 +34,6 @@ export const activityTypeEnum = pgEnum("activity_type", [
|
||||
"participant_added",
|
||||
"participant_updated",
|
||||
"participant_removed",
|
||||
"experiment_created",
|
||||
"experiment_updated",
|
||||
"experiment_deleted",
|
||||
"trial_started",
|
||||
"trial_completed",
|
||||
"trial_cancelled",
|
||||
"invitation_sent",
|
||||
"invitation_accepted",
|
||||
"invitation_declined",
|
||||
@@ -61,33 +54,13 @@ export const invitationStatusEnum = pgEnum("invitation_status", [
|
||||
"revoked",
|
||||
]);
|
||||
|
||||
export const studyActivityTypeEnum = pgEnum("study_activity_type", [
|
||||
"member_added",
|
||||
"member_role_changed",
|
||||
"study_updated",
|
||||
"participant_added",
|
||||
"participant_updated",
|
||||
"invitation_sent",
|
||||
"invitation_accepted",
|
||||
"invitation_declined",
|
||||
"invitation_expired",
|
||||
"invitation_revoked",
|
||||
]);
|
||||
|
||||
// Create enum for experiment status
|
||||
export const experimentStatusEnum = pgEnum("experiment_status", [
|
||||
"draft",
|
||||
"active",
|
||||
"archived",
|
||||
]);
|
||||
|
||||
export const studies = createTable("study", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: text("description"),
|
||||
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
createdById: varchar("created_by", { length: 255 }).references(() => users.id),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const studyMembers = createTable("study_member", {
|
||||
@@ -95,7 +68,7 @@ export const studyMembers = createTable("study_member", {
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
role: studyRoleEnum("role").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const studyMetadata = createTable("study_metadata", {
|
||||
@@ -103,8 +76,8 @@ export const studyMetadata = createTable("study_metadata", {
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
key: varchar("key", { length: 256 }).notNull(),
|
||||
value: text("value"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const studyActivities = createTable("study_activity", {
|
||||
@@ -113,22 +86,20 @@ export const studyActivities = createTable("study_activity", {
|
||||
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id),
|
||||
type: activityTypeEnum("type").notNull(),
|
||||
description: text("description").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const participants = createTable("participant", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
// Identifiable information - only visible to roles with VIEW_PARTICIPANT_NAMES permission
|
||||
identifier: varchar("identifier", { length: 256 }),
|
||||
email: varchar("email", { length: 256 }),
|
||||
firstName: varchar("first_name", { length: 256 }),
|
||||
lastName: varchar("last_name", { length: 256 }),
|
||||
// Non-identifiable information - visible to all study members
|
||||
notes: text("notes"),
|
||||
status: participantStatusEnum("status").notNull().default("active"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const studyInvitations = createTable("study_invitation", {
|
||||
@@ -138,32 +109,21 @@ export const studyInvitations = createTable("study_invitation", {
|
||||
role: studyRoleEnum("role").notNull(),
|
||||
token: varchar("token", { length: 255 }).notNull().unique(),
|
||||
status: invitationStatusEnum("status").notNull().default("pending"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
||||
});
|
||||
|
||||
export const experiments = createTable("experiment", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: text("description"),
|
||||
version: integer("version").notNull().default(1),
|
||||
status: experimentStatusEnum("status").notNull().default("draft"),
|
||||
steps: jsonb("steps").$type<Step[]>().default([]),
|
||||
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const studiesRelations = relations(studies, ({ one, many }) => ({
|
||||
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
|
||||
creator: one(users, {
|
||||
fields: [studies.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
members: many(studyMembers),
|
||||
participants: many(participants),
|
||||
invitations: many(studyInvitations),
|
||||
experiments: many(experiments),
|
||||
}));
|
||||
|
||||
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
|
||||
@@ -179,8 +139,3 @@ export const studyInvitationsRelations = relations(studyInvitations, ({ one }) =
|
||||
study: one(studies, { fields: [studyInvitations.studyId], references: [studies.id] }),
|
||||
creator: one(users, { fields: [studyInvitations.createdById], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const experimentsRelations = relations(experiments, ({ one }) => ({
|
||||
study: one(studies, { fields: [experiments.studyId], references: [studies.id] }),
|
||||
creator: one(users, { fields: [experiments.createdById], references: [users.id] }),
|
||||
}));
|
||||
29
src/server/db/seed.ts
Normal file
29
src/server/db/seed.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { db } from "./index";
|
||||
import { pluginRepositories } from "./schema/store";
|
||||
|
||||
async function seed() {
|
||||
console.log("🌱 Seeding database...");
|
||||
|
||||
// Seed official repository with minimal info
|
||||
// The store will load the full metadata from GitHub Pages when initialized
|
||||
await db.insert(pluginRepositories).values({
|
||||
id: "hristudio-official",
|
||||
url: "https://soconnor0919.github.io/robot-plugins",
|
||||
trust: "official",
|
||||
enabled: true,
|
||||
lastSyncedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: pluginRepositories.id,
|
||||
set: {
|
||||
url: "https://soconnor0919.github.io/robot-plugins",
|
||||
lastSyncedAt: new Date(),
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ Database seeded!");
|
||||
}
|
||||
|
||||
seed().catch((error) => {
|
||||
console.error("Failed to seed database:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Base colors */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 200 50% 20%;
|
||||
|
||||
/* Primary colors */
|
||||
--primary: 200 85% 45%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Card colors and elevation */
|
||||
--card-level-1: 200 30% 98%;
|
||||
--card-level-2: 200 30% 96%;
|
||||
--card-level-3: 200 30% 94%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 200 50% 20%;
|
||||
|
||||
/* Button and interactive states */
|
||||
--secondary: 200 30% 96%;
|
||||
--secondary-foreground: 200 50% 20%;
|
||||
--muted: 200 30% 96%;
|
||||
--muted-foreground: 200 30% 40%;
|
||||
--accent: 200 30% 96%;
|
||||
--accent-foreground: 200 50% 20%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Border and ring */
|
||||
--border: 200 30% 90%;
|
||||
--input: 200 30% 90%;
|
||||
--ring: 200 85% 45%;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* More subtle primary blue to match sidebar */
|
||||
--primary: 200 85% 45%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Slightly tinted card backgrounds */
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 200 30% 25%;
|
||||
|
||||
/* Popover styling */
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 200 50% 20%;
|
||||
|
||||
/* Softer secondary colors */
|
||||
--secondary: 200 30% 96%;
|
||||
--secondary-foreground: 200 50% 20%;
|
||||
|
||||
/* Muted tones with slight blue tint */
|
||||
--muted: 200 30% 96%;
|
||||
--muted-foreground: 200 30% 40%;
|
||||
|
||||
/* Accent colors with more pop */
|
||||
--accent: 200 85% 45%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
/* Brighter destructive red */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Subtle borders and inputs */
|
||||
--border: 200 30% 90%;
|
||||
--input: 200 30% 90%;
|
||||
--ring: 200 85% 45%;
|
||||
|
||||
/* Card elevation levels with blue tint */
|
||||
--card-level-1: 200 30% 98%;
|
||||
--card-level-2: 200 30% 96%;
|
||||
--card-level-3: 200 30% 94%;
|
||||
|
||||
/* Sidebar specific colors */
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 200 50% 20%;
|
||||
--sidebar-muted: 200 30% 40%;
|
||||
--sidebar-hover: 200 40% 95%;
|
||||
--sidebar-border: 200 30% 92%;
|
||||
--sidebar-separator: 200 30% 92%;
|
||||
--sidebar-active: var(--primary);
|
||||
--sidebar-active-foreground: var(--primary-foreground);
|
||||
|
||||
/* Sidebar gradient colors - more subtle blue */
|
||||
--sidebar-gradient-from: 200 40% 85%;
|
||||
--sidebar-gradient-to: 200 35% 80%;
|
||||
|
||||
/* Sidebar text colors */
|
||||
--sidebar-text: 200 30% 25%;
|
||||
--sidebar-text-muted: 200 25% 45%;
|
||||
--sidebar-text-hover: 200 30% 25%;
|
||||
|
||||
/* Gradient */
|
||||
--gradient-start: 200 40% 85%;
|
||||
--gradient-end: 200 35% 80%;
|
||||
|
||||
/* Sidebar and Header Gradients - matching subtle blue */
|
||||
--sidebar-gradient-from: 200 40% 85%;
|
||||
--sidebar-gradient-to: 200 35% 80%;
|
||||
|
||||
/* Sidebar Colors - subtle blue */
|
||||
--sidebar-accent: 200 30% 95%;
|
||||
--sidebar-accent-foreground: 200 40% 45%;
|
||||
--sidebar-primary: 200 40% 45%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Card styling to match sidebar aesthetic */
|
||||
--card-border: 200 30% 90%;
|
||||
--card-hover: 200 40% 98%;
|
||||
|
||||
/* Hover states - more subtle */
|
||||
--hover-background: 200 40% 98%;
|
||||
--hover-foreground: 200 30% 25%;
|
||||
--hover-border: 200 30% 85%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: 200 50% 10%;
|
||||
--foreground: 200 20% 96%;
|
||||
|
||||
/* Card colors - dark */
|
||||
--card-level-1: 200 25% 15%;
|
||||
--card-level-2: 200 25% 18%;
|
||||
--card-level-3: 200 25% 20%;
|
||||
--card: 200 25% 15%;
|
||||
--card-foreground: 200 20% 96%;
|
||||
|
||||
/* Button and interactive states - dark */
|
||||
--secondary: 200 30% 20%;
|
||||
--secondary-foreground: 200 20% 96%;
|
||||
--muted: 200 30% 20%;
|
||||
--muted-foreground: 200 30% 65%;
|
||||
--accent: 200 30% 20%;
|
||||
--accent-foreground: 200 20% 96%;
|
||||
|
||||
/* Border and ring - dark */
|
||||
--border: 200 30% 20%;
|
||||
--input: 200 30% 20%;
|
||||
|
||||
/* Darker theme with blue undertones */
|
||||
--background: 200 50% 10%;
|
||||
--foreground: 200 20% 96%;
|
||||
|
||||
/* Card and surface colors */
|
||||
--card: 200 25% 15%;
|
||||
--card-foreground: 200 15% 85%;
|
||||
|
||||
/* Popover styling */
|
||||
--popover: 200 50% 8%;
|
||||
--popover-foreground: 200 20% 96%;
|
||||
|
||||
/* Vibrant primary in dark mode */
|
||||
--primary: 200 85% 45%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
/* Secondary colors */
|
||||
--secondary: 200 30% 20%;
|
||||
--secondary-foreground: 200 20% 96%;
|
||||
|
||||
/* Muted colors with better visibility */
|
||||
--muted: 200 30% 20%;
|
||||
--muted-foreground: 200 30% 65%;
|
||||
|
||||
/* Accent colors */
|
||||
--accent: 200 85% 45%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
/* Destructive red */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Border and input colors */
|
||||
--border: 200 30% 20%;
|
||||
--input: 200 30% 20%;
|
||||
--ring: 200 85% 45%;
|
||||
|
||||
/* Card elevation levels */
|
||||
--card-level-1: 200 25% 18%;
|
||||
--card-level-2: 200 25% 20%;
|
||||
--card-level-3: 200 25% 22%;
|
||||
|
||||
/* Sidebar specific colors */
|
||||
--sidebar-background: 200 50% 8%;
|
||||
--sidebar-foreground: 200 20% 96%;
|
||||
--sidebar-muted: 200 30% 65%;
|
||||
--sidebar-hover: 200 25% 20%;
|
||||
--sidebar-border: 200 30% 20%;
|
||||
--sidebar-separator: 200 30% 20%;
|
||||
--sidebar-active: 200 85% 60%;
|
||||
--sidebar-active-foreground: 200 50% 10%;
|
||||
|
||||
/* Sidebar gradient colors - more subtle dark blue */
|
||||
--sidebar-gradient-from: 200 25% 30%;
|
||||
--sidebar-gradient-to: 200 20% 25%;
|
||||
|
||||
/* Sidebar text colors for dark mode */
|
||||
--sidebar-text: 0 0% 100%;
|
||||
--sidebar-text-muted: 200 15% 85%;
|
||||
--sidebar-text-hover: 0 0% 100%;
|
||||
|
||||
/* Gradient */
|
||||
--gradient-start: 200 25% 30%;
|
||||
--gradient-end: 200 20% 25%;
|
||||
|
||||
/* Card styling for dark mode */
|
||||
--card-border: 200 20% 25%;
|
||||
--card-hover: 200 25% 20%;
|
||||
|
||||
/* Hover states for dark mode */
|
||||
--hover-background: 200 25% 20%;
|
||||
--hover-foreground: 200 15% 85%;
|
||||
--hover-border: 200 20% 30%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Add these utility classes */
|
||||
.card-level-1 {
|
||||
background-color: hsl(var(--card-level-1));
|
||||
}
|
||||
|
||||
.card-level-2 {
|
||||
background-color: hsl(var(--card-level-2));
|
||||
}
|
||||
|
||||
.card-level-3 {
|
||||
background-color: hsl(var(--card-level-3));
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar specific styles */
|
||||
[data-sidebar="sidebar"] {
|
||||
@apply bg-gradient-to-b from-[hsl(var(--sidebar-gradient-from))] to-[hsl(var(--sidebar-gradient-to))];
|
||||
}
|
||||
|
||||
.sidebar-separator {
|
||||
@apply my-3 border-t border-[hsl(var(--sidebar-text-muted))]/10;
|
||||
}
|
||||
|
||||
.sidebar-dropdown-content {
|
||||
@apply bg-[hsl(var(--sidebar-gradient-from))] border-[hsl(var(--sidebar-text))]/10;
|
||||
}
|
||||
|
||||
/* Sidebar text styles */
|
||||
[data-sidebar="sidebar"] {
|
||||
@apply text-[hsl(var(--sidebar-text))];
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"] {
|
||||
@apply text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-hover))]/20 transition-colors duration-200;
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"][data-active="true"] {
|
||||
@apply bg-[hsl(var(--sidebar-hover))]/30 text-[hsl(var(--sidebar-text))] font-medium;
|
||||
}
|
||||
|
||||
[data-sidebar="group-label"] {
|
||||
@apply text-[hsl(var(--sidebar-text-muted))];
|
||||
}
|
||||
|
||||
[data-sidebar="menu-action"],
|
||||
[data-sidebar="group-action"] {
|
||||
@apply text-[hsl(var(--sidebar-text-muted))] hover:text-[hsl(var(--sidebar-text))] hover:bg-[hsl(var(--sidebar-hover))]/20 transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* Card elevation utilities */
|
||||
.card-level-1 {
|
||||
@apply shadow-[0_4px_6px_-1px_rgba(0,0,0,0.05),0_2px_4px_-1px_rgba(0,0,0,0.05)] hover:shadow-[0_6px_8px_-1px_rgba(0,0,0,0.05),0_4px_6px_-1px_rgba(0,0,0,0.05)];
|
||||
}
|
||||
|
||||
.card-level-2 {
|
||||
@apply shadow-[0_8px_10px_-2px_rgba(0,0,0,0.05),0_4px_6px_-2px_rgba(0,0,0,0.05)] hover:shadow-[0_10px_12px_-2px_rgba(0,0,0,0.05),0_6px_8px_-2px_rgba(0,0,0,0.05)];
|
||||
}
|
||||
|
||||
.card-level-3 {
|
||||
@apply shadow-[0_12px_14px_-3px_rgba(0,0,0,0.05),0_6px_8px_-3px_rgba(0,0,0,0.05)] hover:shadow-[0_14px_16px_-3px_rgba(0,0,0,0.05),0_8px_10px_-3px_rgba(0,0,0,0.05)];
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
.card {
|
||||
@apply bg-card text-card-foreground border border-[hsl(var(--card-border))] rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200;
|
||||
@apply hover:bg-[hsl(var(--card-hover))];
|
||||
}
|
||||
|
||||
/* Add floating effect to cards */
|
||||
.card-floating {
|
||||
@apply transform hover:-translate-y-0.5 transition-all duration-200;
|
||||
}
|
||||
@@ -41,4 +41,4 @@ const createContext = cache(async () => {
|
||||
const getQueryClient = cache(() => createQueryClient(defaultQueryClientOptions));
|
||||
const getCaller = cache(async () => appRouter.createCaller(await createContext()));
|
||||
|
||||
export { api };
|
||||
export { api, getCaller };
|
||||
|
||||
@@ -2,74 +2,92 @@ import { type Config } from "tailwindcss";
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.tsx"],
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}", "./src/**/*.{md,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'var(--font-geist-sans)',
|
||||
...fontFamily.sans
|
||||
]
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))'
|
||||
}
|
||||
}
|
||||
}
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-geist-sans)", ...fontFamily.sans],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
"1": "hsl(var(--chart-1))",
|
||||
"2": "hsl(var(--chart-2))",
|
||||
"3": "hsl(var(--chart-3))",
|
||||
"4": "hsl(var(--chart-4))",
|
||||
"5": "hsl(var(--chart-5))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
|
||||
Reference in New Issue
Block a user