diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2102ca142a0bdd91d73f49cd43666d6181713b39 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM node:18-alpine + +# Use the existing node user (usually UID 1000) +# Set up environment variables for the node user +ENV HOME=/home/node \ + PATH=/home/node/.local/bin:$PATH + +# Create and set up app directory owned by node user +# Go to user's home directory first to ensure it exists +WORKDIR $HOME +RUN mkdir -p $HOME/app && \ + chown -R node:node $HOME/app && \ + chmod -R 755 $HOME/app # Set initial permissions +WORKDIR $HOME/app + +# Switch to the node user +USER node + +# Copy package files (owned by node) +COPY --chown=node:node package*.json ./ + +# Install dependencies +RUN npm install + +# Copy the entire viewer directory (owned by node) +COPY --chown=node:node . . + +# Build the application +RUN npm run build + +# Expose port +EXPOSE 7860 + +# Start the application +CMD ["npm", "run", "preview", "--", "--port", "7860", "--host"] diff --git a/README.md b/README.md index e7b4c5152ca2f9f0ec158fa100b8e00aca3f12cd..083efe0f56d6974914d6e57ddb1fb175d68b8a91 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,84 @@ --- title: LeLab -emoji: 🌍 -colorFrom: pink -colorTo: purple +emoji: ⚡ +colorFrom: yellow +colorTo: red sdk: docker +app_port: 7860 pinned: false -license: mit short_description: Simple Interface to use LeRobot --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# Welcome to your Lovable project + +## Project info + +**URL**: https://lovable.dev/projects/58aee4a4-2f51-49a3-a4d9-56d3d66140b4 + +## How can I edit this code? + +There are several ways of editing your application. + +**Use Lovable** + +Simply visit the [Lovable Project](https://lovable.dev/projects/58aee4a4-2f51-49a3-a4d9-56d3d66140b4) and start prompting. + +Changes made via Lovable will be committed automatically to this repo. + +**Use your preferred IDE** + +If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable. + +The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating) + +Follow these steps: + +```sh +# Step 1: Clone the repository using the project's Git URL. +git clone <YOUR_GIT_URL> + +# Step 2: Navigate to the project directory. +cd <YOUR_PROJECT_NAME> + +# Step 3: Install the necessary dependencies. +npm i + +# Step 4: Start the development server with auto-reloading and an instant preview. +npm run dev +``` + +**Edit a file directly in GitHub** + +- Navigate to the desired file(s). +- Click the "Edit" button (pencil icon) at the top right of the file view. +- Make your changes and commit the changes. + +**Use GitHub Codespaces** + +- Navigate to the main page of your repository. +- Click on the "Code" button (green button) near the top right. +- Select the "Codespaces" tab. +- Click on "New codespace" to launch a new Codespace environment. +- Edit files directly within the Codespace and commit and push your changes once you're done. + +## What technologies are used for this project? + +This project is built with: + +- Vite +- TypeScript +- React +- shadcn-ui +- Tailwind CSS + +## How can I deploy this project? + +Simply open [Lovable](https://lovable.dev/projects/58aee4a4-2f51-49a3-a4d9-56d3d66140b4) and click on Share -> Publish. + +## Can I connect a custom domain to my Lovable project? + +Yes, you can! + +To connect a domain, navigate to Project > Settings > Domains and click Connect Domain. + +Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide) diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..b702660b4dc6db8e51738fda2a789e20b861a08b --- /dev/null +++ b/bun.lockb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9ef82613bb5c109eaa4b79a4432e742b40c028b8841047e5af1bd2941e15d91 +size 228980 diff --git a/components.json b/components.json new file mode 100644 index 0000000000000000000000000000000000000000..f29e3f1610677762f3d97aaefc9ce40a9d410948 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..e67846f70fbbe40407fc84875913595ab31c4a47 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "@typescript-eslint/no-unused-vars": "off", + }, + } +); diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..cd01319749496c9c51cedb191b328208b3053b9f --- /dev/null +++ b/index.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>robot-insight-control-panel</title> + <meta name="description" content="Lovable Generated Project" /> + <meta name="author" content="Lovable" /> + + <meta property="og:title" content="robot-insight-control-panel" /> + <meta property="og:description" content="Lovable Generated Project" /> + <meta property="og:type" content="website" /> + <meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> + + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:site" content="@lovable_dev" /> + <meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> + </head> + + <body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> + </body> +</html> diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..d8b5178b4b101a99c3ae4fc58f8e090bf18f6940 --- /dev/null +++ b/package.json @@ -0,0 +1,88 @@ +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context-menu": "^2.2.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-menubar": "^1.1.1", + "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "@react-three/drei": "^9.122.0", + "@react-three/fiber": "^8.18.0", + "@tanstack/react-query": "^5.56.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.3.0", + "input-otp": "^1.2.4", + "jszip": "^3.10.1", + "lucide-react": "^0.462.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "react-resizable-panels": "^2.1.3", + "react-router-dom": "^6.26.2", + "recharts": "^2.12.7", + "sonner": "^1.5.0", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7", + "three": "^0.177.0", + "urdf-loader": "^0.12.6", + "vaul": "^0.9.3", + "zod": "^3.23.8" + }, + "devDependencies": { + "@eslint/js": "^9.9.0", + "@tailwindcss/typography": "^0.5.15", + "@types/node": "^22.5.5", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.9.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "lovable-tagger": "^1.1.7", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.11", + "typescript": "^5.5.3", + "typescript-eslint": "^8.0.1", + "vite": "^5.4.1" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2e7af2b7f1a6f391da1631d93968a9d487ba977d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..dd5a12627d36db7eb9c19fa2f931ff1509f0323e Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png b/public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png new file mode 100644 index 0000000000000000000000000000000000000000..39595804e566c6c8298b4dfbb31bf7f314c2e562 Binary files /dev/null and b/public/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png differ diff --git a/public/placeholder.svg b/public/placeholder.svg new file mode 100644 index 0000000000000000000000000000000000000000..e763910b27fdd9ac872f56baede51bc839402347 --- /dev/null +++ b/public/placeholder.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..6018e701fc7dd0317cda9eceea390524322e8a05 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/public/so-101-urdf/CMakeLists.txt b/public/so-101-urdf/CMakeLists.txt new file mode 100755 index 0000000000000000000000000000000000000000..e33efd9b8bbbec3086795d7fb9f8fc8bcb1c2bd4 --- /dev/null +++ b/public/so-101-urdf/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.10.2) + +project(so_arm_description) + +find_package(ament_cmake REQUIRED) +find_package(urdf REQUIRED) + +# Install the mesh files from SO101/assets +install( + DIRECTORY + SO101/assets/ + DESTINATION + share/${PROJECT_NAME}/meshes + FILES_MATCHING PATTERN "*.stl" +) + +# Install URDF files +install( + DIRECTORY + urdf/ + DESTINATION + share/${PROJECT_NAME}/urdf + FILES_MATCHING PATTERN "*.urdf" +) + +# Install other directories +install( + DIRECTORY + meshes + config + launch + DESTINATION + share/${PROJECT_NAME} + OPTIONAL +) + +ament_package() + diff --git a/public/so-101-urdf/README.md b/public/so-101-urdf/README.md new file mode 100644 index 0000000000000000000000000000000000000000..dacc2193dbaf13bc1584bc208e28bf9ca32e9cbe --- /dev/null +++ b/public/so-101-urdf/README.md @@ -0,0 +1,41 @@ +# SO-ARM ROS2 URDF Package + +A complete ROS2 package for the SO-ARM101 robotic arm with URDF description. + +## 📋 Overview + +This package provides a complete ROS2 implementation for the SO-ARM101 robotic arm, including: +- URDF robot description with visual and collision meshes +- RViz visualization with pre-configured displays +- Launch files for easy robot visualization +- Integration with MoveIt for motion planning +- Joint state publishers for interactive control + +## 🎯 Original Source +https://github.com/TheRobotStudio/SO-ARM100/tree/main/Simulation/SO101 + + +## 🚀 Key Improvements Made + +### 1. **Complete ROS2 Package Structure** +- ✅ Proper `package.xml` with all necessary dependencies +- ✅ CMakeLists.txt for ROS2 build system +- ✅ Organized directory structure following ROS2 conventions + +### 2. **Enhanced Visualization** +- ✅ Fixed mesh file paths for proper package integration + + +### Build Instructions +1. Clone this repository into your ROS2 workspace: + ```bash + cd ~/your_ros2_ws/src + git clone <your-repo-url> so_arm_description + ``` + +2. Build the package: + ```bash + cd ~/your_ros2_ws + colcon build --packages-select so_arm_description + source install/setup.bash + ``` \ No newline at end of file diff --git a/public/so-101-urdf/config/joint_names_so_arm_urdf.yaml b/public/so-101-urdf/config/joint_names_so_arm_urdf.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1f0c831755649001a8ba1a88e81e162b944a710c --- /dev/null +++ b/public/so-101-urdf/config/joint_names_so_arm_urdf.yaml @@ -0,0 +1 @@ +controller_joint_names: ['', 'Rotation', 'Pitch', 'Elbow', 'Wrist_Pitch', 'Wrist_Roll', 'Jaw', ] diff --git a/public/so-101-urdf/joints_properties.xml b/public/so-101-urdf/joints_properties.xml new file mode 100644 index 0000000000000000000000000000000000000000..0e2b2a5f6f2b16755821d1cd90d38884891b0e97 --- /dev/null +++ b/public/so-101-urdf/joints_properties.xml @@ -0,0 +1,12 @@ +<default> + <default class="sts3215"> + <geom contype="0" conaffinity="0"/> + <joint damping="0.60" frictionloss="0.052" armature="0.028"/> + <position kp="17.8" kv="0.0" forcerange="-3.35 3.35"/> + </default> + <default class="backlash"> + <!-- +/- 0.5° of backlash --> + <joint damping="0.01" frictionloss="0" armature="0.01" limited="true" + range="-0.008726646259971648 0.008726646259971648"/> + </default> +</default> \ No newline at end of file diff --git a/public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl b/public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..ac9c38076fe1036517faf0bccadea5de9dce0097 --- /dev/null +++ b/public/so-101-urdf/meshes/base_motor_holder_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cd2f241037ea377af1191fffe0dd9d9006beea6dcc48543660ed41647072424 +size 1877084 diff --git a/public/so-101-urdf/meshes/base_so101_v2.stl b/public/so-101-urdf/meshes/base_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..503d30be06a91e401ba8d46ebb7e650866229550 --- /dev/null +++ b/public/so-101-urdf/meshes/base_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb12b7026575e1f70ccc7240051f9d943553bf34e5128537de6cd86fae33924d +size 471584 diff --git a/public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl b/public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..f8e3d75c027f28bb672f830ec6e0795567c1b7c9 --- /dev/null +++ b/public/so-101-urdf/meshes/motor_holder_so101_base_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31242ae6fb59d8b15c66617b88ad8e9bded62d57c35d11c0c43a70d2f4caa95b +size 1129384 diff --git a/public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl b/public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..e55b7194683c6ac301504c6f59137362f0ebd13e --- /dev/null +++ b/public/so-101-urdf/meshes/motor_holder_so101_wrist_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:887f92e6013cb64ea3a1ab8675e92da1e0beacfd5e001f972523540545e08011 +size 1052184 diff --git a/public/so-101-urdf/meshes/moving_jaw_so101_v1.stl b/public/so-101-urdf/meshes/moving_jaw_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..eb17d253df8a84a88472ecc7f859d3b8b4d78884 --- /dev/null +++ b/public/so-101-urdf/meshes/moving_jaw_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:785a9dded2f474bc1d869e0d3dae398a3dcd9c0c345640040472210d2861fa9d +size 1413584 diff --git a/public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl b/public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..b536cb4100c1f204f8a9d9b182acdc4a3afbc66c --- /dev/null +++ b/public/so-101-urdf/meshes/rotation_pitch_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9be900cc2a2bf718102841ef82ef8d2873842427648092c8ed2ca1e2ef4ffa34 +size 883684 diff --git a/public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl b/public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..18e9335673f6d46ea8fd0a03a791516203eb6f4c --- /dev/null +++ b/public/so-101-urdf/meshes/sts3215_03a_no_horn_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75ef3781b752e4065891aea855e34dc161a38a549549cd0970cedd07eae6f887 +size 865884 diff --git a/public/so-101-urdf/meshes/sts3215_03a_v1.stl b/public/so-101-urdf/meshes/sts3215_03a_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..a14c57b9033b82f1daa38f45e7e3c91343702df4 --- /dev/null +++ b/public/so-101-urdf/meshes/sts3215_03a_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a37c871fb502483ab96c256baf457d36f2e97afc9205313d9c5ab275ef941cd0 +size 954084 diff --git a/public/so-101-urdf/meshes/under_arm_so101_v1.stl b/public/so-101-urdf/meshes/under_arm_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..47b611ef939e452f791ae749756f717317922cfd --- /dev/null +++ b/public/so-101-urdf/meshes/under_arm_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d01d1f2de365651dcad9d6669e94ff87ff7652b5bb2d10752a66a456a86dbc71 +size 1975884 diff --git a/public/so-101-urdf/meshes/upper_arm_so101_v1.stl b/public/so-101-urdf/meshes/upper_arm_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..8832740f9540065e6006907a9a826b01f96cd122 --- /dev/null +++ b/public/so-101-urdf/meshes/upper_arm_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:475056e03a17e71919b82fd88ab9a0b898ab50164f2a7943652a6b2941bb2d4f +size 1303484 diff --git a/public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl b/public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..e0d90d5b6554ab8ca91928fa6521a675089beb12 --- /dev/null +++ b/public/so-101-urdf/meshes/waveshare_mounting_plate_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e197e24005a07d01bbc06a8c42311664eaeda415bf859f68fa247884d0f1a6e9 +size 62784 diff --git a/public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl b/public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl new file mode 100644 index 0000000000000000000000000000000000000000..9a5fa8fe2d7d8e59cd4a30d4dba0ca337513ab4a --- /dev/null +++ b/public/so-101-urdf/meshes/wrist_roll_follower_so101_v1.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b17b410a12d64ec39554abc3e8054d8a97384b2dc4a8d95a5ecb2a93670f5f4 +size 1439884 diff --git a/public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl b/public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl new file mode 100644 index 0000000000000000000000000000000000000000..2f531712f88ec01d09824ee8e27c791a4616516f --- /dev/null +++ b/public/so-101-urdf/meshes/wrist_roll_pitch_so101_v2.stl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c7ec5525b4d8b9e397a30ab4bb0037156a5d5f38a4adf2c7d943d6c56eda5ae +size 2699784 diff --git a/public/so-101-urdf/package.xml b/public/so-101-urdf/package.xml new file mode 100644 index 0000000000000000000000000000000000000000..ff52e481f026862b7094d3746579bc8a7ad9672d --- /dev/null +++ b/public/so-101-urdf/package.xml @@ -0,0 +1,26 @@ +<?xml version="1.0"?> +<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> +<package format="3"> + <name>so_arm_description</name> + <version>1.0.0</version> + <description>SO-ARM101 URDF Resources</description> + + <author email="contact@lycheeai-hub.com">LycheeAI</author> + + <maintainer email="contact@lycheeai-hub.com">LycheeAI</maintainer> + + <license>BSD</license> + + <buildtool_depend>ament_cmake</buildtool_depend> + + <depend>urdf</depend> + <exec_depend>robot_state_publisher</exec_depend> + <exec_depend>joint_state_publisher</exec_depend> + <exec_depend>joint_state_publisher_gui</exec_depend> + <exec_depend>rviz2</exec_depend> + <exec_depend>xacro</exec_depend> + + <export> + <build_type>ament_cmake</build_type> + </export> +</package> \ No newline at end of file diff --git a/public/so-101-urdf/urdf/so101_new_calib.urdf b/public/so-101-urdf/urdf/so101_new_calib.urdf new file mode 100644 index 0000000000000000000000000000000000000000..557c565bd3d227be739bc81de64d2f9ff3f80699 --- /dev/null +++ b/public/so-101-urdf/urdf/so101_new_calib.urdf @@ -0,0 +1,435 @@ +<?xml version='1.0' encoding='utf-8'?> +<!-- Generated using onshape-to-robot --> +<!-- Onshape https://cad.onshape.com/documents/7715cc284bb430fe6dab4ffd/w/4fd0791b683777b02f8d975a/e/826c553ede3b7592eb9ca800 --> +<robot name="so101_new_calib"> + + <!-- Materials --> + <material name="3d_printed"> + <color rgba="1.0 0.82 0.12 1.0"/> + </material> + <material name="sts3215"> + <color rgba="0.1 0.1 0.1 1.0"/> + </material> + + <!-- Link base --> + <link name="base"> + <inertial> + <origin xyz="0.020739 0.00204287 0.065966" rpy="0 0 0"/> + <mass value="0.147"/> + <inertia ixx="0.000136117" ixy="4.59787e-07" ixz="9.75275e-08" iyy="0.000114686" iyz="-4.97151e-06" izz="0.000130364"/> + </inertial> + <!-- Part base_motor_holder_so101_v1 --> + <visual> + <origin xyz="0.0206915 0.0221255 0.0300817" rpy="1.5708 -1.23909e-16 2.33147e-15"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/base_motor_holder_so101_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="0.0206915 0.0221255 0.0300817" rpy="1.5708 -1.23909e-16 2.33147e-15"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/base_motor_holder_so101_v1.stl"/> + </geometry> + </collision> + <!-- Part base_so101_v2 --> + <visual> + <origin xyz="0.0207909 0.0221255 0.0300817" rpy="1.5708 -0 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/base_so101_v2.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="0.0207909 0.0221255 0.0300817" rpy="1.5708 -0 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/base_so101_v2.stl"/> + </geometry> + </collision> + <!-- Part sts3215_03a_v1 --> + <visual> + <origin xyz="0.0207909 -0.0105745 0.0761817" rpy="-2.20282e-15 2.77556e-17 -1.5708"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + <material name="sts3215"/> + </visual> + <collision> + <origin xyz="0.0207909 -0.0105745 0.0761817" rpy="-2.20282e-15 2.77556e-17 -1.5708"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + </collision> + <!-- Part waveshare_mounting_plate_so101_v2 --> + <visual> + <origin xyz="0.0205915 0.0467435 0.0798817" rpy="1.5708 -1.21716e-14 2.33147e-15"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/waveshare_mounting_plate_so101_v2.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="0.0205915 0.0467435 0.0798817" rpy="1.5708 -1.21716e-14 2.33147e-15"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/waveshare_mounting_plate_so101_v2.stl"/> + </geometry> + </collision> + </link> + + <!-- Link shoulder --> + <link name="shoulder"> + <inertial> + <origin xyz="-0.0307604 -1.66727e-05 -0.0252713" rpy="0 0 0"/> + <mass value="0.100006"/> + <inertia ixx="8.3759e-05" ixy="7.55525e-08" ixz="-1.16342e-06" iyy="8.10403e-05" iyz="1.54663e-07" izz="2.39783e-05"/> + </inertial> + <!-- Part sts3215_03a_v1_2 --> + <visual> + <origin xyz="-0.0303992 0.000422241 -0.0417" rpy="1.5708 1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + <material name="sts3215"/> + </visual> + <collision> + <origin xyz="-0.0303992 0.000422241 -0.0417" rpy="1.5708 1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + </collision> + <!-- Part motor_holder_so101_base_v1 --> + <visual> + <origin xyz="-0.0675992 -0.000177759 0.0158499" rpy="1.5708 -1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/motor_holder_so101_base_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="-0.0675992 -0.000177759 0.0158499" rpy="1.5708 -1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/motor_holder_so101_base_v1.stl"/> + </geometry> + </collision> + <!-- Part rotation_pitch_so101_v1 --> + <visual> + <origin xyz="0.0122008 2.22413e-05 0.0464" rpy="-1.5708 -0 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/rotation_pitch_so101_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="0.0122008 2.22413e-05 0.0464" rpy="-1.5708 -0 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/rotation_pitch_so101_v1.stl"/> + </geometry> + </collision> + </link> + + <!-- Link upper_arm --> + <link name="upper_arm"> + <inertial> + <origin xyz="-0.0898471 -0.00838224 0.0184089" rpy="0 0 0"/> + <mass value="0.103"/> + <inertia ixx="4.08002e-05" ixy="-1.97819e-05" ixz="-4.03016e-08" iyy="0.000147318" iyz="8.97326e-09" izz="0.000142487"/> + </inertial> + <!-- Part sts3215_03a_v1_3 --> + <visual> + <origin xyz="-0.11257 -0.0155 0.0187" rpy="-3.14159 -6.8695e-16 -1.5708"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + <material name="sts3215"/> + </visual> + <collision> + <origin xyz="-0.11257 -0.0155 0.0187" rpy="-3.14159 -6.8695e-16 -1.5708"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + </collision> + <!-- Part upper_arm_so101_v1 --> + <visual> + <origin xyz="-0.065085 0.012 0.0182" rpy="3.14159 -9.35612e-32 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/upper_arm_so101_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="-0.065085 0.012 0.0182" rpy="3.14159 -9.35612e-32 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/upper_arm_so101_v1.stl"/> + </geometry> + </collision> + </link> + + <!-- Link lower_arm --> + <link name="lower_arm"> + <inertial> + <origin xyz="-0.0980701 0.00324376 0.0182831" rpy="0 0 0"/> + <mass value="0.104"/> + <inertia ixx="2.87438e-05" ixy="7.41152e-06" ixz="1.26409e-06" iyy="0.000159844" iyz="-4.90188e-08" izz="0.00014529"/> + </inertial> + <!-- Part under_arm_so101_v1 --> + <visual> + <origin xyz="-0.0648499 -0.032 0.0182" rpy="-3.14159 -0 3.9443e-31"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/under_arm_so101_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="-0.0648499 -0.032 0.0182" rpy="-3.14159 -0 3.9443e-31"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/under_arm_so101_v1.stl"/> + </geometry> + </collision> + <!-- Part motor_holder_so101_wrist_v1 --> + <visual> + <origin xyz="-0.0648499 -0.032 0.018" rpy="-3.14159 4.73317e-30 7.88861e-31"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/motor_holder_so101_wrist_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="-0.0648499 -0.032 0.018" rpy="-3.14159 4.73317e-30 7.88861e-31"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/motor_holder_so101_wrist_v1.stl"/> + </geometry> + </collision> + <!-- Part sts3215_03a_v1_4 --> + <visual> + <origin xyz="-0.1224 0.0052 0.0187" rpy="-3.14159 -3.58047e-15 -3.14159"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + <material name="sts3215"/> + </visual> + <collision> + <origin xyz="-0.1224 0.0052 0.0187" rpy="-3.14159 -3.58047e-15 -3.14159"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + </collision> + </link> + + <!-- Link wrist --> + <link name="wrist"> + <inertial> + <origin xyz="-0.000103312 -0.0386143 0.0281156" rpy="0 0 0"/> + <mass value="0.079"/> + <inertia ixx="3.68263e-05" ixy="1.7893e-08" ixz="-5.28128e-08" iyy="2.5391e-05" iyz="3.6412e-06" izz="2.1e-05"/> + </inertial> + <!-- Part sts3215_03a_no_horn_v1 --> + <visual> + <origin xyz="5.55112e-17 -0.0424 0.0306" rpy="1.5708 1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_no_horn_v1.stl"/> + </geometry> + <material name="sts3215"/> + </visual> + <collision> + <origin xyz="5.55112e-17 -0.0424 0.0306" rpy="1.5708 1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_no_horn_v1.stl"/> + </geometry> + </collision> + <!-- Part wrist_roll_pitch_so101_v2 --> + <visual> + <origin xyz="0 -0.028 0.0181" rpy="-1.5708 -1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/wrist_roll_pitch_so101_v2.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="0 -0.028 0.0181" rpy="-1.5708 -1.5708 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/wrist_roll_pitch_so101_v2.stl"/> + </geometry> + </collision> + </link> + + <!-- Link gripper --> + <link name="gripper"> + <inertial> + <origin xyz="0.000213627 0.000245138 -0.025187" rpy="0 0 0"/> + <mass value="0.087"/> + <inertia ixx="2.75087e-05" ixy="-3.35241e-07" ixz="-5.7352e-06" iyy="4.33657e-05" iyz="-5.17847e-08" izz="3.45059e-05"/> + </inertial> + <!-- Part sts3215_03a_v1_5 --> + <visual> + <origin xyz="0.0077 0.0001 -0.0234" rpy="-1.5708 -5.55112e-17 -1.38213e-14"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + <material name="sts3215"/> + </visual> + <collision> + <origin xyz="0.0077 0.0001 -0.0234" rpy="-1.5708 -5.55112e-17 -1.38213e-14"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/sts3215_03a_v1.stl"/> + </geometry> + </collision> + <!-- Part wrist_roll_follower_so101_v1 --> + <visual> + <origin xyz="5.55112e-17 -0.000218214 0.000949706" rpy="-3.14159 -5.55112e-17 -9.17912e-24"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/wrist_roll_follower_so101_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="5.55112e-17 -0.000218214 0.000949706" rpy="-3.14159 -5.55112e-17 -9.17912e-24"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/wrist_roll_follower_so101_v1.stl"/> + </geometry> + </collision> + </link> + + <!-- Link jaw --> + <link name="jaw"> + <inertial> + <origin xyz="-0.00157495 -0.0300244 0.0192755" rpy="0 0 0"/> + <mass value="0.012"/> + <inertia ixx="6.61427e-06" ixy="-3.19807e-07" ixz="-5.90717e-09" iyy="1.89032e-06" iyz="-1.09945e-07" izz="5.28738e-06"/> + </inertial> + <!-- Part moving_jaw_so101_v1 --> + <visual> + <origin xyz="-5.55112e-17 -1.94746e-17 0.0189" rpy="9.53145e-17 -4.66093e-24 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/moving_jaw_so101_v1.stl"/> + </geometry> + <material name="3d_printed"/> + </visual> + <collision> + <origin xyz="-5.55112e-17 -1.94746e-17 0.0189" rpy="9.53145e-17 -4.66093e-24 0"/> + <geometry> + <mesh filename="package://so_arm_description/meshes/moving_jaw_so101_v1.stl"/> + </geometry> + </collision> + </link> + + <!-- Joint from gripper to jaw --> + <joint name="Jaw" type="revolute"> + <origin xyz="0.0202 0.0188 -0.0234" rpy="1.5708 -5.14108e-17 -1.38655e-14"/> + <parent link="gripper"/> + <child link="jaw"/> + <axis xyz="0 0 1"/> + <limit effort="10" velocity="10" lower="-0.174533" upper="1.74533"/> + </joint> + + <transmission name="6_trans"> + <type>transmission_interface/SimpleTransmission</type> + <joint name="Jaw"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + </joint> + <actuator name="motor6"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + <mechanicalReduction>1</mechanicalReduction> + </actuator> + </transmission> + + <!-- Joint from wrist to gripper --> + <joint name="Wrist_Roll" type="revolute"> + <origin xyz="0 -0.0611 0.0181" rpy="1.5708 -9.38083e-08 3.14159"/> + <parent link="wrist"/> + <child link="gripper"/> + <axis xyz="0 0 1"/> + <limit effort="10" velocity="10" lower="-2.79253" upper="2.79253"/> + </joint> + + <transmission name="5_trans"> + <type>transmission_interface/SimpleTransmission</type> + <joint name="Wrist_Roll"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + </joint> + <actuator name="motor5"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + <mechanicalReduction>1</mechanicalReduction> + </actuator> + </transmission> + + <!-- Joint from lower_arm to wrist --> + <joint name="Wrist_Pitch" type="revolute"> + <origin xyz="-0.1349 0.0052 1.65232e-16" rpy="3.2474e-15 2.86219e-15 -1.5708"/> + <parent link="lower_arm"/> + <child link="wrist"/> + <axis xyz="0 0 1"/> + <limit effort="10" velocity="10" lower="-1.65806" upper="1.65806"/> + </joint> + + <transmission name="4_trans"> + <type>transmission_interface/SimpleTransmission</type> + <joint name="Wrist_Pitch"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + </joint> + <actuator name="motor4"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + <mechanicalReduction>1</mechanicalReduction> + </actuator> + </transmission> + + <!-- Joint from upper_arm to lower_arm --> + <joint name="Elbow" type="revolute"> + <origin xyz="-0.11257 -0.028 2.46331e-16" rpy="-1.22818e-15 5.75928e-16 1.5708"/> + <parent link="upper_arm"/> + <child link="lower_arm"/> + <axis xyz="0 0 1"/> + <limit effort="10" velocity="10" lower="-1.74533" upper="1.5708"/> + </joint> + + <transmission name="3_trans"> + <type>transmission_interface/SimpleTransmission</type> + <joint name="Elbow"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + </joint> + <actuator name="motor3"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + <mechanicalReduction>1</mechanicalReduction> + </actuator> + </transmission> + + <!-- Joint from shoulder to upper_arm --> + <joint name="Pitch" type="revolute"> + <origin xyz="-0.0303992 -0.0182778 -0.0542" rpy="-1.5708 -1.5708 0"/> + <parent link="shoulder"/> + <child link="upper_arm"/> + <axis xyz="0 0 1"/> + <limit effort="10" velocity="10" lower="-1.74533" upper="1.74533"/> + </joint> + + <transmission name="2_trans"> + <type>transmission_interface/SimpleTransmission</type> + <joint name="Pitch"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + </joint> + <actuator name="motor2"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + <mechanicalReduction>1</mechanicalReduction> + </actuator> + </transmission> + + <!-- Joint from base to shoulder --> + <joint name="Rotation" type="revolute"> + <origin xyz="0.0207909 -0.0230745 0.0948817" rpy="-3.14159 6.03684e-16 1.5708"/> + <parent link="base"/> + <child link="shoulder"/> + <axis xyz="0 0 1"/> + <limit effort="10" velocity="10" lower="-1.91986" upper="1.91986"/> + </joint> + + <transmission name="1_trans"> + <type>transmission_interface/SimpleTransmission</type> + <joint name="Rotation"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + </joint> + <actuator name="motor1"> + <hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface> + <mechanicalReduction>1</mechanicalReduction> + </actuator> + </transmission> + +</robot> \ No newline at end of file diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..40b878db5b1c97fc77049537a71bb2e249abe5dc --- /dev/null +++ b/src/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..b9d355df2a5956b526c004531b7b0ffe412461e0 --- /dev/null +++ b/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea561c8cb3d1db7b0da5bfb69ca57c7f4653a715 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,40 @@ +import { Toaster } from "@/components/ui/toaster"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import Index from "./pages/Index"; +import NotFound from "./pages/NotFound"; +import Landing from "./pages/Landing"; +import TeleoperationPage from "./pages/Teleoperation"; +import Recording from "./pages/Recording"; +import Calibration from "./pages/Calibration"; +import Training from "./pages/Training"; +import { UrdfProvider } from "./contexts/UrdfContext"; + +const queryClient = new QueryClient(); + +const App = () => ( + <QueryClientProvider client={queryClient}> + <TooltipProvider> + <Toaster /> + <Sonner /> + <UrdfProvider> + <BrowserRouter> + <Routes> + <Route path="/" element={<Landing />} /> + <Route path="/control" element={<Index />} /> + <Route path="/teleoperation" element={<TeleoperationPage />} /> + <Route path="/recording" element={<Recording />} /> + <Route path="/calibration" element={<Calibration />} /> + <Route path="/training" element={<Training />} /> + {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} + <Route path="*" element={<NotFound />} /> + </Routes> + </BrowserRouter> + </UrdfProvider> + </TooltipProvider> + </QueryClientProvider> +); + +export default App; diff --git a/src/components/UrdfProcessorInitializer.tsx b/src/components/UrdfProcessorInitializer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29fec7193ab0aa64fa934379cfc2c07c0f0c40e0 --- /dev/null +++ b/src/components/UrdfProcessorInitializer.tsx @@ -0,0 +1,40 @@ +import React, { useEffect, useMemo } from "react"; +import { useUrdf } from "@/hooks/useUrdf"; + +/** + * Component that only handles initializing the URDF processor + * This component doesn't render anything visible, just initializes the processor + */ +const UrdfProcessorInitializer: React.FC = () => { + const { registerUrdfProcessor } = useUrdf(); + + // Create the URDF processor + const urdfProcessor = useMemo( + () => ({ + loadUrdf: (urdfPath: string) => { + console.log("📂 URDF path set:", urdfPath); + // This will be handled by the actual viewer component + return urdfPath; + }, + setUrlModifierFunc: (func: (url: string) => string) => { + console.log("🔗 URL modifier function set"); + return func; + }, + getPackage: () => { + return ""; + }, + }), + [] + ); + + // Register the URDF processor with the context + useEffect(() => { + console.log("🔧 Registering URDF processor"); + registerUrdfProcessor(urdfProcessor); + }, [registerUrdfProcessor, urdfProcessor]); + + // This component doesn't render anything + return null; +}; + +export default UrdfProcessorInitializer; diff --git a/src/components/UrdfViewer.tsx b/src/components/UrdfViewer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..99c21dd90e69f4703c0479e7c048481fbc2a4936 --- /dev/null +++ b/src/components/UrdfViewer.tsx @@ -0,0 +1,236 @@ +import React, { + useEffect, + useRef, + useState, + useMemo, + useCallback, +} from "react"; +import { cn } from "@/lib/utils"; + +import URDFManipulator from "urdf-loader/src/urdf-manipulator-element.js"; +import { useUrdf } from "@/hooks/useUrdf"; +import { useRealTimeJoints } from "@/hooks/useRealTimeJoints"; +import { + createUrdfViewer, + setupMeshLoader, + setupJointHighlighting, + setupModelLoading, + URDFViewerElement, +} from "@/lib/urdfViewerHelpers"; + +// Register the URDFManipulator as a custom element if it hasn't been already +if (typeof window !== "undefined" && !customElements.get("urdf-viewer")) { + customElements.define("urdf-viewer", URDFManipulator); +} + +// Extend the interface for the URDF viewer element to include background property +interface UrdfViewerElement extends HTMLElement { + background?: string; + setJointValue?: (jointName: string, value: number) => void; +} + +const UrdfViewer: React.FC = () => { + const containerRef = useRef<HTMLDivElement>(null); + const [highlightedJoint, setHighlightedJoint] = useState<string | null>(null); + const { registerUrdfProcessor, alternativeUrdfModels, isDefaultModel } = + useUrdf(); + + // Add state for animation control + useState<boolean>(isDefaultModel); + const cleanupAnimationRef = useRef<(() => void) | null>(null); + const viewerRef = useRef<URDFViewerElement | null>(null); + const hasInitializedRef = useRef<boolean>(false); + + // Real-time joint updates via WebSocket + const { isConnected: isWebSocketConnected } = useRealTimeJoints({ + viewerRef, + enabled: isDefaultModel, // Only enable WebSocket for default model + }); + + // Add state for custom URDF path + const [customUrdfPath, setCustomUrdfPath] = useState<string | null>(null); + const [urlModifierFunc, setUrlModifierFunc] = useState< + ((url: string) => string) | null + >(null); + + const packageRef = useRef<string>(""); + + // Implement UrdfProcessor interface for drag and drop + const urdfProcessor = useMemo( + () => ({ + loadUrdf: (urdfPath: string) => { + setCustomUrdfPath(urdfPath); + }, + setUrlModifierFunc: (func: (url: string) => string) => { + setUrlModifierFunc(() => func); + }, + getPackage: () => { + return packageRef.current; + }, + }), + [] + ); + + // Register the URDF processor with the global drag and drop context + useEffect(() => { + registerUrdfProcessor(urdfProcessor); + }, [registerUrdfProcessor, urdfProcessor]); + + // Create URL modifier function for default model + const defaultUrlModifier = useCallback((url: string) => { + console.log(`🔗 defaultUrlModifier called with: ${url}`); + + // Handle various package:// URL formats for the default SO-101 model + if (url.startsWith("package://so_arm_description/meshes/")) { + const modifiedUrl = url.replace( + "package://so_arm_description/meshes/", + "/so-101-urdf/meshes/" + ); + console.log(`🔗 Modified URL (package): ${modifiedUrl}`); + return modifiedUrl; + } + + // Handle case where package path might be partially resolved + if (url.includes("so_arm_description/meshes/")) { + const modifiedUrl = url.replace( + /.*so_arm_description\/meshes\//, + "/so-101-urdf/meshes/" + ); + console.log(`🔗 Modified URL (partial): ${modifiedUrl}`); + return modifiedUrl; + } + + // Handle the specific problematic path pattern we're seeing in logs + if (url.includes("/so-101-urdf/so_arm_description/meshes/")) { + const modifiedUrl = url.replace( + "/so-101-urdf/so_arm_description/meshes/", + "/so-101-urdf/meshes/" + ); + console.log(`🔗 Modified URL (problematic path): ${modifiedUrl}`); + return modifiedUrl; + } + + // Handle relative paths that might need mesh folder prefix + if ( + url.endsWith(".stl") && + !url.startsWith("/") && + !url.startsWith("http") + ) { + const modifiedUrl = `/so-101-urdf/meshes/${url}`; + console.log(`🔗 Modified URL (relative): ${modifiedUrl}`); + return modifiedUrl; + } + + console.log(`🔗 Unmodified URL: ${url}`); + return url; + }, []); + + // Main effect to create and setup the viewer only once + useEffect(() => { + if (!containerRef.current) return; + + // Create and configure the URDF viewer element + const viewer = createUrdfViewer(containerRef.current, true); + viewerRef.current = viewer; // Store reference to the viewer + + // Setup mesh loading function with appropriate URL modifier + const activeUrlModifier = isDefaultModel + ? defaultUrlModifier + : urlModifierFunc; + setupMeshLoader(viewer, activeUrlModifier); + + // Determine which URDF to load - fixed path to match the actual available file + const urdfPath = isDefaultModel + ? "/so-101-urdf/urdf/so101_new_calib.urdf" + : customUrdfPath || ""; + + // Set the package path for the default model + if (isDefaultModel) { + packageRef.current = "/"; // Set to root so we can handle full path resolution in URL modifier + } + + // Setup model loading if a path is available + let cleanupModelLoading = () => {}; + if (urdfPath) { + cleanupModelLoading = setupModelLoading( + viewer, + urdfPath, + packageRef.current, + setCustomUrdfPath, + alternativeUrdfModels + ); + } + + // Setup joint highlighting + const cleanupJointHighlighting = setupJointHighlighting( + viewer, + setHighlightedJoint + ); + + // Setup animation event handler for the default model or when hasAnimation is true + const onModelProcessed = () => { + hasInitializedRef.current = true; + if ("setJointValue" in viewer) { + // Clear any existing animation + if (cleanupAnimationRef.current) { + cleanupAnimationRef.current(); + cleanupAnimationRef.current = null; + } + } + }; + + viewer.addEventListener("urdf-processed", onModelProcessed); + + // Return cleanup function + return () => { + if (cleanupAnimationRef.current) { + cleanupAnimationRef.current(); + cleanupAnimationRef.current = null; + } + hasInitializedRef.current = false; + cleanupJointHighlighting(); + cleanupModelLoading(); + viewer.removeEventListener("urdf-processed", onModelProcessed); + }; + }, [isDefaultModel, customUrdfPath, urlModifierFunc, defaultUrlModifier]); + + return ( + <div + className={cn( + "w-full h-full transition-all duration-300 ease-in-out relative", + "bg-gradient-to-br from-gray-900 to-gray-800" + )} + > + <div ref={containerRef} className="w-full h-full" /> + + {/* Joint highlight indicator */} + {highlightedJoint && ( + <div className="absolute bottom-4 right-4 bg-black/70 text-white px-3 py-2 rounded-md text-sm font-mono z-10"> + Joint: {highlightedJoint} + </div> + )} + + {/* WebSocket connection status */} + {isDefaultModel && ( + <div className="absolute top-4 right-4 z-10"> + <div + className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono ${ + isWebSocketConnected + ? "bg-green-900/70 text-green-300" + : "bg-red-900/70 text-red-300" + }`} + > + <div + className={`w-2 h-2 rounded-full ${ + isWebSocketConnected ? "bg-green-400" : "bg-red-400" + }`} + /> + {isWebSocketConnected ? "Live Robot Data" : "Disconnected"} + </div> + </div> + )} + </div> + ); +}; + +export default UrdfViewer; diff --git a/src/components/control/CommandBar.tsx b/src/components/control/CommandBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea5032d6d0330981e08f39b4ce460da6d1918024 --- /dev/null +++ b/src/components/control/CommandBar.tsx @@ -0,0 +1,81 @@ + +import React from 'react'; +import { Mic, MicOff, Send, Camera } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +interface CommandBarProps { + command: string; + setCommand: (command: string) => void; + handleSendCommand: () => void; + isVoiceActive: boolean; + setIsVoiceActive: (isActive: boolean) => void; + showCamera: boolean; + setShowCamera: (show: boolean) => void; + handleEndSession: () => void; +} + +const CommandBar: React.FC<CommandBarProps> = ({ + command, + setCommand, + handleSendCommand, + isVoiceActive, + setIsVoiceActive, + showCamera, + setShowCamera, + handleEndSession +}) => { + return ( + <div className="bg-gray-900 p-4 space-y-4"> + <div className="flex flex-col sm:flex-row gap-4 items-center max-w-4xl mx-auto w-full"> + <Input + value={command} + onChange={(e) => setCommand(e.target.value)} + placeholder="Tell the robot what to do..." + className="flex-1 bg-gray-800 border-gray-600 text-white placeholder-gray-400 text-lg py-3" + onKeyPress={(e) => e.key === 'Enter' && handleSendCommand()} + /> + <Button + onClick={handleSendCommand} + className="bg-orange-500 hover:bg-orange-600 px-6 py-3 self-stretch sm:self-auto" + > + <Send strokeWidth={1.5} /> + Send + </Button> + </div> + + <div className="flex justify-center items-center gap-6"> + <div className="flex flex-wrap justify-center gap-2 sm:gap-4"> + <Button + onClick={() => setIsVoiceActive(!isVoiceActive)} + className={`px-6 py-2 ${ + isVoiceActive ? 'bg-gray-600 text-white hover:bg-gray-500' : 'bg-gray-800 text-gray-300 hover:bg-gray-700' + }`} + > + {isVoiceActive ? <Mic strokeWidth={1.5} /> : <MicOff strokeWidth={1.5} />} + Voice Command + </Button> + + <Button + onClick={() => setShowCamera(!showCamera)} + className={`px-6 py-2 ${ + showCamera ? 'bg-gray-600 text-white hover:bg-gray-500' : 'bg-gray-800 text-gray-300 hover:bg-gray-700' + }`} + > + <Camera strokeWidth={1.5} /> + Show Camera + </Button> + + <Button + onClick={handleEndSession} + className="bg-red-600 hover:bg-red-700 px-6 py-2" + > + End Session + </Button> + </div> + </div> + </div> + ); +}; + +export default CommandBar; diff --git a/src/components/control/MetricsPanel.tsx b/src/components/control/MetricsPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2485cfa9b79a0bb0d4d20129df4d82f26b4bac96 --- /dev/null +++ b/src/components/control/MetricsPanel.tsx @@ -0,0 +1,190 @@ + +import React, { useEffect, useRef } from 'react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Camera, MicOff } from 'lucide-react'; + +interface MetricsPanelProps { + activeTab: 'SENSORS' | 'MOTORS'; + setActiveTab: (tab: 'SENSORS' | 'MOTORS') => void; + sensorData: any[]; + motorData: any[]; + hasPermissions: boolean; + streamRef: React.RefObject<MediaStream | null>; + isVoiceActive: boolean; + micLevel: number; +} + +const MetricsPanel: React.FC<MetricsPanelProps> = ({ + activeTab, + setActiveTab, + sensorData, + motorData, + hasPermissions, + streamRef, + isVoiceActive, + micLevel, +}) => { + const sensorVideoRef = useRef<HTMLVideoElement>(null); + + useEffect(() => { + if (activeTab === 'SENSORS' && hasPermissions && sensorVideoRef.current && streamRef.current) { + if (sensorVideoRef.current.srcObject !== streamRef.current) { + sensorVideoRef.current.srcObject = streamRef.current; + } + } + }, [activeTab, hasPermissions, streamRef]); + + return ( + <div className="w-full lg:w-1/2 p-2 sm:p-4"> + <div className="bg-gray-900 rounded-lg p-4 h-full flex flex-col"> + {/* Tab Headers */} + <div className="flex mb-4"> + <button + onClick={() => setActiveTab('MOTORS')} + className={`px-6 py-2 rounded-t-lg text-sm sm:text-base ${ + activeTab === 'MOTORS' + ? 'bg-orange-500 text-white' + : 'bg-gray-700 text-gray-300 hover:bg-gray-600' + }`} + > + MOTORS + </button> + <button + onClick={() => setActiveTab('SENSORS')} + className={`px-6 py-2 rounded-t-lg ml-2 text-sm sm:text-base ${ + activeTab === 'SENSORS' + ? 'bg-orange-500 text-white' + : 'bg-gray-700 text-gray-300 hover:bg-gray-600' + }`} + > + SENSORS + </button> + </div> + + {/* Chart Content */} + <div className="flex-1 overflow-y-auto"> + {activeTab === 'SENSORS' && ( + <div className="space-y-4"> + {/* Webcam Feed */} + <div className="border border-gray-800 rounded p-2 flex flex-col h-64"> + <h3 className="text-sm text-white font-medium mb-2">Live Camera Feed</h3> + {hasPermissions ? ( + <div className="flex-1 bg-black rounded overflow-hidden"> + <video + ref={sensorVideoRef} + autoPlay + muted + playsInline + className="w-full h-full object-contain" + /> + </div> + ) : ( + <div className="flex-1 flex items-center justify-center bg-black rounded"> + <div className="text-center"> + <Camera className="w-12 h-12 mx-auto text-gray-500 mb-2" /> + <p className="text-gray-400">Camera permission not granted.</p> + </div> + </div> + )} + </div> + + {/* Mic Detection & Other Sensors */} + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + <div className="border border-gray-800 rounded p-2 flex flex-col justify-center min-h-[120px]"> + <h3 className="text-sm text-center text-white font-medium mb-2">Voice Activity</h3> + {hasPermissions ? ( + <div className="flex-1 flex flex-col items-center justify-center gap-2 text-center"> + <div className="flex items-end h-10 gap-px w-full justify-center"> + {[...Array(15)].map((_, i) => { + const barIsActive = isVoiceActive && i < (micLevel / 120 * 15); + return ( + <div + key={i} + className={`w-1.5 rounded-full transition-colors duration-75 ${barIsActive ? 'bg-orange-500' : 'bg-gray-700'}`} + style={{ height: `${(i / 15 * 60) + 20}%` }} + /> + ); + })} + </div> + <p className="text-xs text-gray-300"> + {isVoiceActive ? "Voice commands active" : "Voice commands muted"} + </p> + </div> + ) : ( + <div className="flex-1 flex items-center justify-center bg-black rounded"> + <div className="text-center"> + <MicOff className="w-8 h-8 mx-auto text-gray-500 mb-2" /> + <p className="text-gray-400">Microphone permission not granted.</p> + </div> + </div> + )} + </div> + + {/* Sensor Charts */} + {['sensor3', 'sensor4'].map((sensor, index) => ( + <div key={sensor} className="border border-gray-800 rounded p-2 flex flex-col h-auto min-h-[120px]"> + <h3 className="text-sm text-white font-medium mb-2">Sensor {index + 3}</h3> + <ResponsiveContainer width="100%" height="90%"> + <LineChart data={sensorData}> + <CartesianGrid strokeDasharray="3 3" stroke="#374151" /> + <XAxis hide /> + <YAxis fontSize={12} stroke="#9CA3AF" /> + <Tooltip + contentStyle={{ + backgroundColor: '#1F2937', + border: '1px solid #374151', + color: '#fff' + }} + /> + <Line + type="monotone" + dataKey={sensor} + stroke={index % 2 === 1 ? '#ff6b35' : '#ffdd44'} + strokeWidth={2} + dot={false} + /> + </LineChart> + </ResponsiveContainer> + </div> + ))} + </div> + </div> + )} + + {activeTab === 'MOTORS' && ( + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + {['motor1', 'motor2', 'motor3', 'motor4', 'motor5', 'motor6'].map((motor, index) => ( + <div key={motor} className="border border-gray-800 rounded p-2 h-40"> + <h3 className="text-sm text-white font-medium mb-2">Motor {index + 1}</h3> + <ResponsiveContainer width="100%" height="80%"> + <LineChart data={motorData}> + <CartesianGrid strokeDasharray="3 3" stroke="#374151" /> + <XAxis hide /> + <YAxis fontSize={12} stroke="#9CA3AF" /> + <Tooltip + contentStyle={{ + backgroundColor: '#1F2937', + border: '1px solid #374151', + color: '#fff' + }} + /> + <Line + type="monotone" + dataKey={motor} + stroke={index % 2 === 0 ? '#ff6b35' : '#ffdd44'} + strokeWidth={2} + dot={false} + /> + </LineChart> + </ResponsiveContainer> + </div> + ))} + </div> + )} + </div> + </div> + </div> + ); +}; + +export default MetricsPanel; diff --git a/src/components/control/RobotArm.tsx b/src/components/control/RobotArm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1defa06aeaf2f4e8e83f38e02c2a7342ed4e5d5e --- /dev/null +++ b/src/components/control/RobotArm.tsx @@ -0,0 +1,40 @@ + +import React from 'react'; + +const RobotArm = () => { + return ( + <group> + {/* Base */} + <mesh position={[0, -0.25, 0]}> + <cylinderGeometry args={[1, 1, 0.5]} /> + <meshPhongMaterial color="#333333" /> + </mesh> + + {/* First joint */} + <mesh position={[0, 0.5, 0]}> + <boxGeometry args={[0.3, 1.5, 0.3]} /> + <meshPhongMaterial color="#ff6b35" /> + </mesh> + + {/* Second segment */} + <mesh position={[0.9, 1.2, 0]} rotation={[0, 0, 0.3]}> + <boxGeometry args={[1.8, 0.25, 0.25]} /> + <meshPhongMaterial color="#ffdd44" /> + </mesh> + + {/* Third segment */} + <mesh position={[1.8, 1.7, 0]} rotation={[0, 0, -0.5]}> + <boxGeometry args={[1.2, 0.2, 0.2]} /> + <meshPhongMaterial color="#ff6b35" /> + </mesh> + + {/* End effector */} + <mesh position={[2.3, 1.3, 0]}> + <boxGeometry args={[0.3, 0.3, 0.15]} /> + <meshPhongMaterial color="#ffdd44" /> + </mesh> + </group> + ); +}; + +export default RobotArm; diff --git a/src/components/control/VisualizerPanel.tsx b/src/components/control/VisualizerPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1350a8efbcf83e3bb7e51fe35dacbcf5ecd7c0b4 --- /dev/null +++ b/src/components/control/VisualizerPanel.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft } from "lucide-react"; +import { cn } from "@/lib/utils"; +import UrdfViewer from "../UrdfViewer"; +import UrdfProcessorInitializer from "../UrdfProcessorInitializer"; + +interface VisualizerPanelProps { + onGoBack: () => void; + className?: string; +} + +const VisualizerPanel: React.FC<VisualizerPanelProps> = ({ + onGoBack, + className, +}) => { + return ( + <div + className={cn( + "w-full lg:w-1/2 p-2 sm:p-4 space-y-4 flex flex-col", + className + )} + > + <div className="bg-gray-900 rounded-lg p-4 flex-1 flex flex-col"> + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center gap-3"> + <img + src="/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png" + alt="LiveLab Logo" + className="h-8 w-8" + /> + <h2 className="text-xl font-bold text-white">LiveLab</h2> + </div> + <Button + variant="ghost" + size="icon" + onClick={onGoBack} + className="text-gray-400 hover:text-white hover:bg-gray-800" + > + <ArrowLeft className="h-5 w-5" /> + </Button> + </div> + <div className="flex-1 bg-black rounded border border-gray-800 min-h-[50vh] lg:min-h-0"> + {/* <Canvas camera={{ position: [5, 3, 5], fov: 50 }}> + <ambientLight intensity={0.4} /> + <directionalLight position={[10, 10, 5]} intensity={1} /> + <RobotArm /> + <OrbitControls enablePan={true} enableZoom={true} enableRotate={true} /> + </Canvas> */} + <UrdfProcessorInitializer /> + <UrdfViewer /> + </div> + </div> + + <div className="grid grid-cols-2 lg:grid-cols-4 gap-2"> + {[1, 2, 3, 4].map((cam) => ( + <div + key={cam} + className="aspect-video bg-gray-900 rounded border border-gray-700 flex items-center justify-center" + > + <span className="text-gray-400 text-sm">Camera {cam}</span> + </div> + ))} + </div> + </div> + ); +}; + +export default VisualizerPanel; diff --git a/src/components/test/WebSocketTest.tsx b/src/components/test/WebSocketTest.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6500714834334dedae6df749b43be67af3e3e88a --- /dev/null +++ b/src/components/test/WebSocketTest.tsx @@ -0,0 +1,131 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; + +interface JointData { + type: "joint_update"; + joints: Record<string, number>; + timestamp: number; +} + +const WebSocketTest: React.FC = () => { + const [isConnected, setIsConnected] = useState(false); + const [lastMessage, setLastMessage] = useState<JointData | null>(null); + const [connectionStatus, setConnectionStatus] = + useState<string>("Disconnected"); + const [ws, setWs] = useState<WebSocket | null>(null); + + const connect = () => { + // First test server health + fetch("http://localhost:8000/health") + .then((response) => response.json()) + .then((data) => { + console.log("Server health:", data); + + // Now try WebSocket connection + const websocket = new WebSocket("ws://localhost:8000/ws/joint-data"); + + websocket.onopen = () => { + console.log("WebSocket connected"); + setIsConnected(true); + setConnectionStatus("Connected"); + setWs(websocket); + }; + + websocket.onmessage = (event) => { + try { + const data: JointData = JSON.parse(event.data); + setLastMessage(data); + console.log("Received joint data:", data); + } catch (error) { + console.error("Error parsing message:", error); + } + }; + + websocket.onclose = (event) => { + console.log("WebSocket closed:", event.code, event.reason); + setIsConnected(false); + setConnectionStatus(`Closed (${event.code})`); + setWs(null); + }; + + websocket.onerror = (error) => { + console.error("WebSocket error:", error); + setConnectionStatus("Error"); + }; + }) + .catch((error) => { + console.error("Server health check failed:", error); + setConnectionStatus("Server unreachable"); + }); + }; + + const disconnect = () => { + if (ws) { + ws.close(); + } + }; + + useEffect(() => { + return () => { + if (ws) { + ws.close(); + } + }; + }, [ws]); + + return ( + <div className="p-4 bg-gray-900 text-white rounded-lg"> + <h3 className="text-lg font-bold mb-4">WebSocket Connection Test</h3> + + <div className="space-y-4"> + <div className="flex items-center gap-4"> + <div + className={`w-3 h-3 rounded-full ${ + isConnected ? "bg-green-500" : "bg-red-500" + }`} + /> + <span>Status: {connectionStatus}</span> + </div> + + <div className="flex gap-2"> + <Button onClick={connect} disabled={isConnected}> + Connect + </Button> + <Button + onClick={disconnect} + disabled={!isConnected} + variant="outline" + > + Disconnect + </Button> + </div> + + {lastMessage && ( + <div className="bg-gray-800 p-3 rounded"> + <h4 className="font-semibold mb-2">Last Joint Data:</h4> + <div className="text-sm font-mono"> + <div> + Timestamp:{" "} + {new Date(lastMessage.timestamp * 1000).toLocaleTimeString()} + </div> + <div className="mt-2">Joints:</div> + {Object.entries(lastMessage.joints).map(([joint, value]) => ( + <div key={joint} className="ml-4"> + {joint}: {value.toFixed(4)} rad ( + {((value * 180) / Math.PI).toFixed(2)}°) + </div> + ))} + </div> + </div> + )} + + <div className="text-sm text-gray-400"> + <div>Expected URL: ws://localhost:8000/ws/joint-data</div> + <div>Make sure your FastAPI server is running!</div> + </div> + </div> + </div> + ); +}; + +export default WebSocketTest; diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e6a723d06574ee5cec8b00759b98f3fbe1ac7cc9 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> +>(({ className, ...props }, ref) => ( + <AccordionPrimitive.Item + ref={ref} + className={cn("border-b", className)} + {...props} + /> +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", + className + )} + {...props} + > + {children} + <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef<typeof AccordionPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Content + ref={ref} + className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + {...props} + > + <div className={cn("pb-4 pt-0", className)}>{children}</div> + </AccordionPrimitive.Content> +)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8722561cf6bda62d62f9a0c67730aefda971873a --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> +>(({ className, ...props }, ref) => ( + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + /> + </AlertDialogPortal> +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold", className)} + {...props} + /> +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Action>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Action + ref={ref} + className={cn(buttonVariants(), className)} + {...props} + /> +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef<typeof AlertDialogPrimitive.Cancel>, + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> +>(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Cancel + ref={ref} + className={cn( + buttonVariants({ variant: "outline" }), + "mt-2 sm:mt-0", + className + )} + {...props} + /> +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000000000000000000000000000000000000..41fa7e0561a3fdb5f986c1213a35e563de740e96 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> +>(({ className, variant, ...props }, ref) => ( + <div + ref={ref} + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} + /> +)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + <h5 + ref={ref} + className={cn("mb-1 font-medium leading-none tracking-tight", className)} + {...props} + /> +)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("text-sm [&_p]:leading-relaxed", className)} + {...props} + /> +)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4abbf37f217c715a0eaade7f45ac78600df419f --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..991f56ecb117e96284bf0f6cad3b14ea2fdf5264 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Root + ref={ref} + className={cn( + "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", + className + )} + {...props} + /> +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Image>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} + /> +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef<typeof AvatarPrimitive.Fallback>, + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> +>(({ className, ...props }, ref) => ( + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-muted", + className + )} + {...props} + /> +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f000e3ef5176395b067dfc3f3e1256a80c450015 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes<HTMLDivElement>, + VariantProps<typeof badgeVariants> {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + <div className={cn(badgeVariants({ variant }), className)} {...props} /> + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71a5c325cdce2e6898d11cfeb4f2fdd458e3e2da --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />) +Breadcrumb.displayName = "Breadcrumb" + +const BreadcrumbList = React.forwardRef< + HTMLOListElement, + React.ComponentPropsWithoutRef<"ol"> +>(({ className, ...props }, ref) => ( + <ol + ref={ref} + className={cn( + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", + className + )} + {...props} + /> +)) +BreadcrumbList.displayName = "BreadcrumbList" + +const BreadcrumbItem = React.forwardRef< + HTMLLIElement, + React.ComponentPropsWithoutRef<"li"> +>(({ className, ...props }, ref) => ( + <li + ref={ref} + className={cn("inline-flex items-center gap-1.5", className)} + {...props} + /> +)) +BreadcrumbItem.displayName = "BreadcrumbItem" + +const BreadcrumbLink = React.forwardRef< + HTMLAnchorElement, + React.ComponentPropsWithoutRef<"a"> & { + asChild?: boolean + } +>(({ asChild, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + <Comp + ref={ref} + className={cn("transition-colors hover:text-foreground", className)} + {...props} + /> + ) +}) +BreadcrumbLink.displayName = "BreadcrumbLink" + +const BreadcrumbPage = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> +>(({ className, ...props }, ref) => ( + <span + ref={ref} + role="link" + aria-disabled="true" + aria-current="page" + className={cn("font-normal text-foreground", className)} + {...props} + /> +)) +BreadcrumbPage.displayName = "BreadcrumbPage" + +const BreadcrumbSeparator = ({ + children, + className, + ...props +}: React.ComponentProps<"li">) => ( + <li + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:size-3.5", className)} + {...props} + > + {children ?? <ChevronRight />} + </li> +) +BreadcrumbSeparator.displayName = "BreadcrumbSeparator" + +const BreadcrumbEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + <span + role="presentation" + aria-hidden="true" + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More</span> + </span> +) +BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" + +export { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36496a28727a3643b4212a14225d4f6cbd50bda5 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes<HTMLButtonElement>, + VariantProps<typeof buttonVariants> { + asChild?: boolean +} + +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} + /> + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3160ad0f396d96ec2f499e3e76aae44656f58497 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,64 @@ +import * as React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { DayPicker } from "react-day-picker"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +export type CalendarProps = React.ComponentProps<typeof DayPicker>; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn("p-3", className)} + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: cn( + buttonVariants({ variant: "outline" }), + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" + ), + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: + "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", + day: cn( + buttonVariants({ variant: "ghost" }), + "h-9 w-9 p-0 font-normal aria-selected:opacity-100" + ), + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />, + IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />, + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..afa13ecfa3bd0f4a553a510b856c5800382e139b --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn( + "rounded-lg border bg-card text-card-foreground shadow-sm", + className + )} + {...props} + /> +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} + /> +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLHeadingElement> +>(({ className, ...props }, ref) => ( + <h3 + ref={ref} + className={cn( + "text-2xl font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => ( + <p + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9c2b9bf3705d8421bef00704c0c52e83d371ca11 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,260 @@ +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +type CarouselApi = UseEmblaCarouselType[1] +type UseCarouselParameters = Parameters<typeof useEmblaCarousel> +type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType<typeof useEmblaCarousel>[0] + api: ReturnType<typeof useEmblaCarousel>[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext<CarouselContextProps | null>(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />") + } + + return context +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent<HTMLDivElement>) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }} + > + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} + </div> + </CarouselContext.Provider> + ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className + )} + {...props} + /> + </div> + ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className + )} + {...props} + /> + ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props} + > + <ArrowLeft className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<typeof Button> +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn( + "absolute h-8 w-8 rounded-full", + orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", + className + )} + disabled={!canScrollNext} + onClick={scrollNext} + {...props} + > + <ArrowRight className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a21d77ee708c3d861fb246ecab7f6dc36e0e605b --- /dev/null +++ b/src/components/ui/chart.tsx @@ -0,0 +1,363 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext<ChartContextProps | null>(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a <ChartContainer />") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + ref={ref} + className={cn( + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + className + )} + {...props} + > + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer> + {children} + </RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map( + ([theme, prefix]) => ` +${prefix} [data-chart=${id}] { +${colorConfig + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color + return color ? ` --color-${key}: ${color};` : null + }) + .join("\n")} +} +` + ) + .join("\n"), + }} + /> + ) +} + +const ChartTooltip = RechartsPrimitive.Tooltip + +const ChartTooltipContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<typeof RechartsPrimitive.Tooltip> & + React.ComponentProps<"div"> & { + hideLabel?: boolean + hideIndicator?: boolean + indicator?: "line" | "dot" | "dashed" + nameKey?: string + labelKey?: string + } +>( + ( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref + ) => { + const { config } = useChart() + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null + } + + const [item] = payload + const key = `${labelKey || item.dataKey || item.name || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const value = + !labelKey && typeof label === "string" + ? config[label as keyof typeof config]?.label || label + : itemConfig?.label + + if (labelFormatter) { + return ( + <div className={cn("font-medium", labelClassName)}> + {labelFormatter(value, payload)} + </div> + ) + } + + if (!value) { + return null + } + + return <div className={cn("font-medium", labelClassName)}>{value}</div> + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]) + + if (!active || !payload?.length) { + return null + } + + const nestLabel = payload.length === 1 && indicator !== "dot" + + return ( + <div + ref={ref} + className={cn( + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )} + > + {!nestLabel ? tooltipLabel : null} + <div className="grid gap-1.5"> + {payload.map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const indicatorColor = color || item.payload.fill || item.color + + return ( + <div + key={item.dataKey} + className={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn( + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", + { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + } + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} + <div + className={cn( + "flex flex-1 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )} + > + <div className="grid gap-1.5"> + {nestLabel ? tooltipLabel : null} + <span className="text-muted-foreground"> + {itemConfig?.label || item.name} + </span> + </div> + {item.value && ( + <span className="font-mono font-medium tabular-nums text-foreground"> + {item.value.toLocaleString()} + </span> + )} + </div> + </> + )} + </div> + ) + })} + </div> + </div> + ) + } +) +ChartTooltipContent.displayName = "ChartTooltip" + +const ChartLegend = RechartsPrimitive.Legend + +const ChartLegendContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { + hideIcon?: boolean + nameKey?: string + } +>( + ( + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, + ref + ) => { + const { config } = useChart() + + if (!payload?.length) { + return null + } + + return ( + <div + ref={ref} + className={cn( + "flex items-center justify-center gap-4", + verticalAlign === "top" ? "pb-3" : "pt-3", + className + )} + > + {payload.map((item) => { + const key = `${nameKey || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + + return ( + <div + key={item.value} + className={cn( + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" + )} + > + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className="h-2 w-2 shrink-0 rounded-[2px]" + style={{ + backgroundColor: item.color, + }} + /> + )} + {itemConfig?.label} + </div> + ) + })} + </div> + ) + } +) +ChartLegendContent.displayName = "ChartLegend" + +// Helper to extract item config from a payload. +function getPayloadConfigFromPayload( + config: ChartConfig, + payload: unknown, + key: string +) { + if (typeof payload !== "object" || payload === null) { + return undefined + } + + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined + + let configLabelKey: string = key + + if ( + key in payload && + typeof payload[key as keyof typeof payload] === "string" + ) { + configLabelKey = payload[key as keyof typeof payload] as string + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === "string" + ) { + configLabelKey = payloadPayload[ + key as keyof typeof payloadPayload + ] as string + } + + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config] +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ddbdd01d8d1491ab772790db8d40c5ac0a2630f3 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef<typeof CheckboxPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> +>(({ className, ...props }, ref) => ( + <CheckboxPrimitive.Root + ref={ref} + className={cn( + "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + className + )} + {...props} + > + <CheckboxPrimitive.Indicator + className={cn("flex items-center justify-center text-current")} + > + <Check className="h-4 w-4" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a23e7a281287e18b1c332498491b6bcc8d8e2b70 --- /dev/null +++ b/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000000000000000000000000000000000000..56a0979c02183994b324f8039cf710cdf786bcb7 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef<typeof CommandPrimitive>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive> +>(({ className, ...props }, ref) => ( + <CommandPrimitive + ref={ref} + className={cn( + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", + className + )} + {...props} + /> +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + <Dialog {...props}> + <DialogContent className="overflow-hidden p-0 shadow-lg"> + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> + {children} + </Command> + </DialogContent> + </Dialog> + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Input>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> +>(({ className, ...props }, ref) => ( + <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <CommandPrimitive.Input + ref={ref} + className={cn( + "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props} + /> + </div> +)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.List>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.List + ref={ref} + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} + {...props} + /> +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Empty>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> +>((props, ref) => ( + <CommandPrimitive.Empty + ref={ref} + className="py-6 text-center text-sm" + {...props} + /> +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Group>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Group + ref={ref} + className={cn( + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", + className + )} + {...props} + /> +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Separator + ref={ref} + className={cn("-mx-1 h-px bg-border", className)} + {...props} + /> +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef<typeof CommandPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> +>(({ className, ...props }, ref) => ( + <CommandPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50", + className + )} + {...props} + /> +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3e5299917fb55decf8ba88f03c964d984c43cb18 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </ContextMenuPrimitive.SubTrigger> +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> +>(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </ContextMenuPrimitive.Portal> +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} + /> +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.CheckboxItem> +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <ContextMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.RadioItem> +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <ContextMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold text-foreground", + inset && "pl-8", + className + )} + {...props} + /> +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef<typeof ContextMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-border", className)} + {...props} + /> +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c23630eb841533af4030574e54106f0fdf0fc677 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + /> +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} + > + {children} + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-1.5 text-center sm:text-left", + className + )} + {...props} + /> +) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef<typeof DialogPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c17b0ccaa95dccfb96c75618e078efd263e54fd0 --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,116 @@ +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps<typeof DrawerPrimitive.Root>) => ( + <DrawerPrimitive.Root + shouldScaleBackground={shouldScaleBackground} + {...props} + /> +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Overlay + ref={ref} + className={cn("fixed inset-0 z-50 bg-black/80", className)} + {...props} + /> +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> +>(({ className, children, ...props }, ref) => ( + <DrawerPortal> + <DrawerOverlay /> + <DrawerPrimitive.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", + className + )} + {...props} + > + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> + {children} + </DrawerPrimitive.Content> + </DrawerPortal> +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} + {...props} + /> +) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn("mt-auto flex flex-col gap-2 p-4", className)} + {...props} + /> +) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Title + ref={ref} + className={cn( + "text-lg font-semibold leading-none tracking-tight", + className + )} + {...props} + /> +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef<typeof DrawerPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description> +>(({ className, ...props }, ref) => ( + <DrawerPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..769ff7aa709f0d7a1afe2a87d180447fc26749e4 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </DropdownMenuPrimitive.SubTrigger> +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </DropdownMenuPrimitive.Portal> +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef<typeof DropdownMenuPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} + /> + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4603f8b3d5eee5fe162bb69a02ce8546d4358cc3 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +}) +FormLabel.displayName = "FormLabel" + +const FormControl = React.forwardRef< + React.ElementRef<typeof Slot>, + React.ComponentPropsWithoutRef<typeof Slot> +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +}) +FormControl.displayName = "FormControl" + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +}) +FormDescription.displayName = "FormDescription" + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message) : children + + if (!body) { + return null + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-sm font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ) +}) +FormMessage.displayName = "FormMessage" + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/src/components/ui/hover-card.tsx b/src/components/ui/hover-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..863ff0148f0bb239b93c44a93baf14073cd3ae23 --- /dev/null +++ b/src/components/ui/hover-card.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef<typeof HoverCardPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <HoverCardPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ebf5c281ee7fbbf86462b33b7b9955d884d57580 --- /dev/null +++ b/src/components/ui/input-otp.tsx @@ -0,0 +1,69 @@ +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { Dot } from "lucide-react" + +import { cn } from "@/lib/utils" + +const InputOTP = React.forwardRef< + React.ElementRef<typeof OTPInput>, + React.ComponentPropsWithoutRef<typeof OTPInput> +>(({ className, containerClassName, ...props }, ref) => ( + <OTPInput + ref={ref} + containerClassName={cn( + "flex items-center gap-2 has-[:disabled]:opacity-50", + containerClassName + )} + className={cn("disabled:cursor-not-allowed", className)} + {...props} + /> +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("flex items-center", className)} {...props} /> +)) +InputOTPGroup.displayName = "InputOTPGroup" + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( + <div + ref={ref} + className={cn( + "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", + isActive && "z-10 ring-2 ring-ring ring-offset-background", + className + )} + {...props} + > + {char} + {hasFakeCaret && ( + <div className="pointer-events-none absolute inset-0 flex items-center justify-center"> + <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> + </div> + )} + </div> + ) +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( + <div ref={ref} role="separator" {...props}> + <Dot /> + </div> +)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..68551b9276b4164a8263aa58d385db30f81a4453 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( + ({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + className + )} + ref={ref} + {...props} + /> + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000000000000000000000000000000000000..683faa793819982d64e21cb2939666fd6d4a7b13 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/menubar.tsx b/src/components/ui/menubar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d11c2993f279769a0df4ba4313e1f0c7c7c7cd28 --- /dev/null +++ b/src/components/ui/menubar.tsx @@ -0,0 +1,234 @@ +import * as React from "react" +import * as MenubarPrimitive from "@radix-ui/react-menubar" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const MenubarMenu = MenubarPrimitive.Menu + +const MenubarGroup = MenubarPrimitive.Group + +const MenubarPortal = MenubarPrimitive.Portal + +const MenubarSub = MenubarPrimitive.Sub + +const MenubarRadioGroup = MenubarPrimitive.RadioGroup + +const Menubar = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.Root + ref={ref} + className={cn( + "flex h-10 items-center space-x-1 rounded-md border bg-background p-1", + className + )} + {...props} + /> +)) +Menubar.displayName = MenubarPrimitive.Root.displayName + +const MenubarTrigger = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.Trigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + className + )} + {...props} + /> +)) +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName + +const MenubarSubTrigger = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.SubTrigger>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + <MenubarPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className + )} + {...props} + > + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </MenubarPrimitive.SubTrigger> +)) +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName + +const MenubarSubContent = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.SubContent>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName + +const MenubarContent = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> +>( + ( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref + ) => ( + <MenubarPrimitive.Portal> + <MenubarPrimitive.Content + ref={ref} + align={align} + alignOffset={alignOffset} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </MenubarPrimitive.Portal> + ) +) +MenubarContent.displayName = MenubarPrimitive.Content.displayName + +const MenubarItem = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <MenubarPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} + /> +)) +MenubarItem.displayName = MenubarPrimitive.Item.displayName + +const MenubarCheckboxItem = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem> +>(({ className, children, checked, ...props }, ref) => ( + <MenubarPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.CheckboxItem> +)) +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName + +const MenubarRadioItem = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.RadioItem>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem> +>(({ className, children, ...props }, ref) => ( + <MenubarPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.RadioItem> +)) +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName + +const MenubarLabel = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + <MenubarPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold", + inset && "pl-8", + className + )} + {...props} + /> +)) +MenubarLabel.displayName = MenubarPrimitive.Label.displayName + +const MenubarSeparator = React.forwardRef< + React.ElementRef<typeof MenubarPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <MenubarPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName + +const MenubarShortcut = ({ + className, + ...props +}: React.HTMLAttributes<HTMLSpanElement>) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className + )} + {...props} + /> + ) +} +MenubarShortcut.displayname = "MenubarShortcut" + +export { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, +} diff --git a/src/components/ui/navigation-menu.tsx b/src/components/ui/navigation-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1419f56695be517ec78fe8ad26a6f7da3a7d53b2 --- /dev/null +++ b/src/components/ui/navigation-menu.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <NavigationMenuPrimitive.Root + ref={ref} + className={cn( + "relative z-10 flex max-w-max flex-1 items-center justify-center", + className + )} + {...props} + > + {children} + <NavigationMenuViewport /> + </NavigationMenuPrimitive.Root> +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.List>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List> +>(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.List + ref={ref} + className={cn( + "group flex flex-1 list-none items-center justify-center space-x-1", + className + )} + {...props} + /> +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" +) + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <NavigationMenuPrimitive.Trigger + ref={ref} + className={cn(navigationMenuTriggerStyle(), "group", className)} + {...props} + > + {children}{" "} + <ChevronDown + className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180" + aria-hidden="true" + /> + </NavigationMenuPrimitive.Trigger> +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content> +>(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.Content + ref={ref} + className={cn( + "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", + className + )} + {...props} + /> +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> +>(({ className, ...props }, ref) => ( + <div className={cn("absolute left-0 top-full flex justify-center")}> + <NavigationMenuPrimitive.Viewport + className={cn( + "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", + className + )} + ref={ref} + {...props} + /> + </div> +)) +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator> +>(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.Indicator + ref={ref} + className={cn( + "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", + className + )} + {...props} + > + <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> + </NavigationMenuPrimitive.Indicator> +)) +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea40d196dc72673f36c4084bf56457385edc855e --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( + <nav + role="navigation" + aria-label="pagination" + className={cn("mx-auto flex w-full justify-center", className)} + {...props} + /> +) +Pagination.displayName = "Pagination" + +const PaginationContent = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + className={cn("flex flex-row items-center gap-1", className)} + {...props} + /> +)) +PaginationContent.displayName = "PaginationContent" + +const PaginationItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ className, ...props }, ref) => ( + <li ref={ref} className={cn("", className)} {...props} /> +)) +PaginationItem.displayName = "PaginationItem" + +type PaginationLinkProps = { + isActive?: boolean +} & Pick<ButtonProps, "size"> & + React.ComponentProps<"a"> + +const PaginationLink = ({ + className, + isActive, + size = "icon", + ...props +}: PaginationLinkProps) => ( + <a + aria-current={isActive ? "page" : undefined} + className={cn( + buttonVariants({ + variant: isActive ? "outline" : "ghost", + size, + }), + className + )} + {...props} + /> +) +PaginationLink.displayName = "PaginationLink" + +const PaginationPrevious = ({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) => ( + <PaginationLink + aria-label="Go to previous page" + size="default" + className={cn("gap-1 pl-2.5", className)} + {...props} + > + <ChevronLeft className="h-4 w-4" /> + <span>Previous</span> + </PaginationLink> +) +PaginationPrevious.displayName = "PaginationPrevious" + +const PaginationNext = ({ + className, + ...props +}: React.ComponentProps<typeof PaginationLink>) => ( + <PaginationLink + aria-label="Go to next page" + size="default" + className={cn("gap-1 pr-2.5", className)} + {...props} + > + <span>Next</span> + <ChevronRight className="h-4 w-4" /> + </PaginationLink> +) +PaginationNext.displayName = "PaginationNext" + +const PaginationEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + <span + aria-hidden + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props} + > + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More pages</span> + </span> +) +PaginationEllipsis.displayName = "PaginationEllipsis" + +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bbba7e0ebf26c29526552f2dc4e8e3ecea3d641a --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef<typeof PopoverPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> + </PopoverPrimitive.Portal> +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..105fb650055b71797510142dfa041b5d80808041 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef<typeof ProgressPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> +>(({ className, value, ...props }, ref) => ( + <ProgressPrimitive.Root + ref={ref} + className={cn( + "relative h-4 w-full overflow-hidden rounded-full bg-secondary", + className + )} + {...props} + > + <ProgressPrimitive.Indicator + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000000000000000000000000000000000000..43b43b48b84d3045a442409006d5cb8c5f35adfd --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Root + className={cn("grid gap-2", className)} + {...props} + ref={ref} + /> + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef<typeof RadioGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> +>(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props} + > + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-2.5 w-2.5 fill-current text-current" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd3cb0ec783fcd72906f275a372d50259d5853b8 --- /dev/null +++ b/src/components/ui/resizable.tsx @@ -0,0 +1,43 @@ +import { GripVertical } from "lucide-react" +import * as ResizablePrimitive from "react-resizable-panels" + +import { cn } from "@/lib/utils" + +const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( + <ResizablePrimitive.PanelGroup + className={cn( + "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", + className + )} + {...props} + /> +) + +const ResizablePanel = ResizablePrimitive.Panel + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { + withHandle?: boolean +}) => ( + <ResizablePrimitive.PanelResizeHandle + className={cn( + "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( + <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> + <GripVertical className="h-2.5 w-2.5" /> + </div> + )} + </ResizablePrimitive.PanelResizeHandle> +) + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cf253cf17056ce04827219643e484ea99c77cf6b --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> +>(({ className, children, ...props }, ref) => ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props} + > + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> +>(({ className, orientation = "vertical", ...props }, ref) => ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe56d4d3ad53f9c3ec9434f2d39feb7571a3b52f --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> +>(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Label>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} + {...props} + /> +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> +>(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props} + > + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef<typeof SelectPrimitive.Separator>, + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> +>(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} + /> +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d7f12265ba0338704f013930ce4d52c56527dd1 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className + )} + {...props} + /> + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7b1dbe4c4515258f91cad0234c685771859e962a --- /dev/null +++ b/src/components/ui/sheet.tsx @@ -0,0 +1,131 @@ +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Overlay>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} + /> +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, + VariantProps<typeof sheetVariants> { } + +const SheetContent = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Content>, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content + ref={ref} + className={cn(sheetVariants({ side }), className)} + {...props} + > + {children} + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + </SheetPrimitive.Content> + </SheetPortal> +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className + )} + {...props} + /> +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) => ( + <div + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className + )} + {...props} + /> +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Title>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold text-foreground", className)} + {...props} + /> +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef<typeof SheetPrimitive.Description>, + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> +>(({ className, ...props }, ref) => ( + <SheetPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, SheetClose, + SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger +} + diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1a566bf5537500fec3e02f3e6249e51d0ec23cea --- /dev/null +++ b/src/components/ui/sidebar.tsx @@ -0,0 +1,761 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { Sheet, SheetContent } from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar:state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContext = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext<SidebarContext | null>(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo<SidebarContext>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as React.CSSProperties + } + className={cn( + "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", + className + )} + ref={ref} + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( + <div + className={cn( + "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", + className + )} + ref={ref} + {...props} + > + {children} + </div> + ) + } + + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + data-sidebar="sidebar" + data-mobile="true" + className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE, + } as React.CSSProperties + } + side={side} + > + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ) + } + + return ( + <div + ref={ref} + className="group peer hidden md:block text-sidebar-foreground" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side} + > + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + "duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" + )} + /> + <div + className={cn( + "duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", + side === "left" + ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" + : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", + // Adjust the padding for floating and inset variants. + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l", + className + )} + {...props} + > + <div + data-sidebar="sidebar" + className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow" + > + {children} + </div> + </div> + </div> + ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef<typeof Button>, + React.ComponentProps<typeof Button> +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <Button + ref={ref} + data-sidebar="trigger" + variant="ghost" + size="icon" + className={cn("h-7 w-7", className)} + onClick={(event) => { + onClick?.(event) + toggleSidebar() + }} + {...props} + > + <PanelLeft /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <button + ref={ref} + data-sidebar="rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", + "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className + )} + {...props} + /> + ) +}) +SidebarRail.displayName = "SidebarRail" + +const SidebarInset = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"main"> +>(({ className, ...props }, ref) => { + return ( + <main + ref={ref} + className={cn( + "relative flex min-h-svh flex-1 flex-col bg-background", + "peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", + className + )} + {...props} + /> + ) +}) +SidebarInset.displayName = "SidebarInset" + +const SidebarInput = React.forwardRef< + React.ElementRef<typeof Input>, + React.ComponentProps<typeof Input> +>(({ className, ...props }, ref) => { + return ( + <Input + ref={ref} + data-sidebar="input" + className={cn( + "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", + className + )} + {...props} + /> + ) +}) +SidebarInput.displayName = "SidebarInput" + +const SidebarHeader = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="header" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ) +}) +SidebarHeader.displayName = "SidebarHeader" + +const SidebarFooter = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} + /> + ) +}) +SidebarFooter.displayName = "SidebarFooter" + +const SidebarSeparator = React.forwardRef< + React.ElementRef<typeof Separator>, + React.ComponentProps<typeof Separator> +>(({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + data-sidebar="separator" + className={cn("mx-2 w-auto bg-sidebar-border", className)} + {...props} + /> + ) +}) +SidebarSeparator.displayName = "SidebarSeparator" + +const SidebarContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="content" + className={cn( + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + className + )} + {...props} + /> + ) +}) +SidebarContent.displayName = "SidebarContent" + +const SidebarGroup = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} + /> + ) +}) +SidebarGroup.displayName = "SidebarGroup" + +const SidebarGroupLabel = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "div" + + return ( + <Comp + ref={ref} + data-sidebar="group-label" + className={cn( + "duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className + )} + {...props} + /> + ) +}) +SidebarGroupLabel.displayName = "SidebarGroupLabel" + +const SidebarGroupAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { asChild?: boolean } +>(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + ref={ref} + data-sidebar="group-action" + className={cn( + "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ) +}) +SidebarGroupAction.displayName = "SidebarGroupAction" + +const SidebarGroupContent = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} + /> +)) +SidebarGroupContent.displayName = "SidebarGroupContent" + +const SidebarMenu = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-1", className)} + {...props} + /> +)) +SidebarMenu.displayName = "SidebarMenu" + +const SidebarMenuItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ className, ...props }, ref) => ( + <li + ref={ref} + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} + /> +)) +SidebarMenuItem.displayName = "SidebarMenuItem" + +const sidebarMenuButtonVariants = cva( + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const SidebarMenuButton = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean + isActive?: boolean + tooltip?: string | React.ComponentProps<typeof TooltipContent> + } & VariantProps<typeof sidebarMenuButtonVariants> +>( + ( + { + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props + }, + ref + ) => { + const Comp = asChild ? Slot : "button" + const { isMobile, state } = useSidebar() + + const button = ( + <Comp + ref={ref} + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ) + + if (!tooltip) { + return button + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + } + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} + /> + </Tooltip> + ) + } +) +SidebarMenuButton.displayName = "SidebarMenuButton" + +const SidebarMenuAction = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> & { + asChild?: boolean + showOnHover?: boolean + } +>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + ref={ref} + data-sidebar="menu-action" + className={cn( + "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + className + )} + {...props} + /> + ) +}) +SidebarMenuAction.displayName = "SidebarMenuAction" + +const SidebarMenuBadge = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> +>(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="menu-badge" + className={cn( + "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> +)) +SidebarMenuBadge.displayName = "SidebarMenuBadge" + +const SidebarMenuSkeleton = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + showIcon?: boolean + } +>(({ className, showIcon = false, ...props }, ref) => { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%` + }, []) + + return ( + <div + ref={ref} + data-sidebar="menu-skeleton" + className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} + {...props} + > + {showIcon && ( + <Skeleton + className="size-4 rounded-md" + data-sidebar="menu-skeleton-icon" + /> + )} + <Skeleton + className="h-4 flex-1 max-w-[--skeleton-width]" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width, + } as React.CSSProperties + } + /> + </div> + ) +}) +SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" + +const SidebarMenuSub = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu-sub" + className={cn( + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> +)) +SidebarMenuSub.displayName = "SidebarMenuSub" + +const SidebarMenuSubItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ ...props }, ref) => <li ref={ref} {...props} />) +SidebarMenuSubItem.displayName = "SidebarMenuSubItem" + +const SidebarMenuSubButton = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps<"a"> & { + asChild?: boolean + size?: "sm" | "md" + isActive?: boolean + } +>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + <Comp + ref={ref} + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} + /> + ) +}) +SidebarMenuSubButton.displayName = "SidebarMenuSubButton" + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01b8b6d4f716ff7c26065bc9e46aebd932729fc1 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes<HTMLDivElement>) { + return ( + <div + className={cn("animate-pulse rounded-md bg-muted", className)} + {...props} + /> + ) +} + +export { Skeleton } diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e161daec03130190dcfb597c8632f9df4ba407f5 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +const Slider = React.forwardRef< + React.ElementRef<typeof SliderPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> +>(({ className, ...props }, ref) => ( + <SliderPrimitive.Root + ref={ref} + className={cn( + "relative flex w-full touch-none select-none items-center", + className + )} + {...props} + > + <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary"> + <SliderPrimitive.Range className="absolute h-full bg-primary" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..35418149c0656c2257baf517e85496ef759677f5 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,29 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner, toast } from "sonner" + +type ToasterProps = React.ComponentProps<typeof Sonner> + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} + /> + ) +} + +export { Toaster, toast } diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa58baa29c676db7b14a37436a9662107b0111ec --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" + )} + /> + </SwitchPrimitives.Root> +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7f3502f8b2820be1d6f0aa4c1ffaa351799c1ed3 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,117 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes<HTMLTableElement> +>(({ className, ...props }, ref) => ( + <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn("w-full caption-bottom text-sm", className)} + {...props} + /> + </div> +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tbody + ref={ref} + className={cn("[&_tr:last-child]:border-0", className)} + {...props} + /> +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes<HTMLTableSectionElement> +>(({ className, ...props }, ref) => ( + <tfoot + ref={ref} + className={cn( + "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes<HTMLTableRowElement> +>(({ className, ...props }, ref) => ( + <tr + ref={ref} + className={cn( + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + className + )} + {...props} + /> +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <th + ref={ref} + className={cn( + "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes<HTMLTableCellElement> +>(({ className, ...props }, ref) => ( + <td + ref={ref} + className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes<HTMLTableCaptionElement> +>(({ className, ...props }, ref) => ( + <caption + ref={ref} + className={cn("mt-4 text-sm text-muted-foreground", className)} + {...props} + /> +)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f57fffdb5a07cd21b12f8c014513475a6980a469 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn( + "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", + className + )} + {...props} + /> +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", + className + )} + {...props} + /> +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + className + )} + {...props} + /> +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f9a6dc56193b728feda6d9b1a3d9302b8880f0b --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface TextareaProps + extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} + +const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( + ({ className, ...props }, ref) => { + return ( + <textarea + className={cn( + "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + className + )} + ref={ref} + {...props} + /> + ) + } +) +Textarea.displayName = "Textarea" + +export { Textarea } diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a822477534192c4df5073e4015f7461e739d3344 --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Viewport>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} + /> +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & + VariantProps<typeof toastVariants> +>(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} + /> + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Action>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", + className + )} + {...props} + /> +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Close>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", + className + )} + toast-close="" + {...props} + > + <X className="h-4 w-4" /> + </ToastPrimitives.Close> +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Title>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold", className)} + {...props} + /> +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef<typeof ToastPrimitives.Description>, + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> +>(({ className, ...props }, ref) => ( + <ToastPrimitives.Description + ref={ref} + className={cn("text-sm opacity-90", className)} + {...props} + /> +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> + +type ToastActionElement = React.ReactElement<typeof ToastAction> + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c67edff67a48c6ac1c46f25a6d468dd461e66cb --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ) + })} + <ToastViewport /> + </ToastProvider> + ) +} diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000000000000000000000000000000000000..afe5da62559ca05a0eb5c9415a1e20bd47b37af9 --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps<typeof toggleVariants> +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef<typeof ToggleGroupPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & + VariantProps<typeof toggleVariants> +>(({ className, variant, size, children, ...props }, ref) => ( + <ToggleGroupPrimitive.Root + ref={ref} + className={cn("flex items-center justify-center gap-1", className)} + {...props} + > + <ToggleGroupContext.Provider value={{ variant, size }}> + {children} + </ToggleGroupContext.Provider> + </ToggleGroupPrimitive.Root> +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef<typeof ToggleGroupPrimitive.Item>, + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & + VariantProps<typeof toggleVariants> +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + <ToggleGroupPrimitive.Item + ref={ref} + className={cn( + toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + }), + className + )} + {...props} + > + {children} + </ToggleGroupPrimitive.Item> + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9ecac28ee639d98fbc161a904b5bca0e022e28d6 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3", + sm: "h-9 px-2.5", + lg: "h-11 px-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef<typeof TogglePrimitive.Root>, + React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & + VariantProps<typeof toggleVariants> +>(({ className, variant, size, ...props }, ref) => ( + <TogglePrimitive.Root + ref={ref} + className={cn(toggleVariants({ variant, size, className }))} + {...props} + /> +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e121f0aea0b32952ae54d7db8965a2c15168b13e --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef<typeof TooltipPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> +>(({ className, sideOffset = 4, ...props }, ref) => ( + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + className + )} + {...props} + /> +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0aef21be259883fe159d3176ab46c6841c28a07 --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -0,0 +1,3 @@ +import { useToast, toast } from "@/hooks/use-toast"; + +export { useToast, toast }; diff --git a/src/contexts/DragAndDropContext.tsx b/src/contexts/DragAndDropContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d9989bd885998add143f17d73b955df3d6cb0f8 --- /dev/null +++ b/src/contexts/DragAndDropContext.tsx @@ -0,0 +1,123 @@ +import React, { + createContext, + useState, + useEffect, + ReactNode, + useCallback, +} from "react"; + +import { processDroppedFiles } from "@/lib/UrdfDragAndDrop"; +import { useUrdf } from "@/hooks/useUrdf"; + +export type DragAndDropContextType = { + isDragging: boolean; + setIsDragging: (isDragging: boolean) => void; + handleDrop: (e: DragEvent) => Promise<void>; +}; + +export const DragAndDropContext = createContext< + DragAndDropContextType | undefined +>(undefined); + +interface DragAndDropProviderProps { + children: ReactNode; +} + +export const DragAndDropProvider: React.FC<DragAndDropProviderProps> = ({ + children, +}) => { + const [isDragging, setIsDragging] = useState(false); + + // Get the Urdf context + const { urdfProcessor, processUrdfFiles } = useUrdf(); + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only set isDragging to false if we're leaving the document + // This prevents flickering when moving between elements + if (!e.relatedTarget || !(e.relatedTarget as Element).closest("html")) { + setIsDragging(false); + } + }; + + const handleDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + console.log("🔄 DragAndDropContext: Drop event detected"); + + if (!e.dataTransfer || !urdfProcessor) { + console.error("❌ No dataTransfer or urdfProcessor available"); + return; + } + + try { + console.log("🔍 Processing dropped files with urdfProcessor"); + + // Process files first + const { availableModels, files } = await processDroppedFiles( + e.dataTransfer, + urdfProcessor + ); + + // Delegate further processing to UrdfContext + await processUrdfFiles(files, availableModels); + } catch (error) { + console.error("❌ Error in handleDrop:", error); + } + }, + [urdfProcessor, processUrdfFiles] + ); + + // Set up global event listeners + useEffect(() => { + document.addEventListener("dragover", handleDragOver); + document.addEventListener("dragenter", handleDragEnter); + document.addEventListener("dragleave", handleDragLeave); + document.addEventListener("drop", handleDrop); + + return () => { + document.removeEventListener("dragover", handleDragOver); + document.removeEventListener("dragenter", handleDragEnter); + document.removeEventListener("dragleave", handleDragLeave); + document.removeEventListener("drop", handleDrop); + }; + }, [handleDrop]); // Re-register when handleDrop changes + + return ( + <DragAndDropContext.Provider + value={{ + isDragging, + setIsDragging, + handleDrop, + }} + > + {children} + {isDragging && ( + <div className="fixed inset-0 bg-primary/10 pointer-events-none z-50 flex items-center justify-center"> + <div className="bg-background p-8 rounded-lg shadow-lg text-center"> + <div className="text-3xl font-bold mb-4">Drop Urdf Files Here</div> + <p className="text-muted-foreground"> + Release to upload your robot model + </p> + </div> + </div> + )} + </DragAndDropContext.Provider> + ); +}; diff --git a/src/contexts/UrdfContext.tsx b/src/contexts/UrdfContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..279c916c59379af381b86120a539105d1b322ac8 --- /dev/null +++ b/src/contexts/UrdfContext.tsx @@ -0,0 +1,636 @@ +import React, { + createContext, + useState, + useCallback, + ReactNode, + useRef, + useEffect, +} from "react"; +import { toast } from "sonner"; +import { UrdfProcessor, readUrdfFileContent } from "@/lib/UrdfDragAndDrop"; +import { UrdfData, UrdfFileModel } from "@/lib/types"; +import { useDefaultRobotData } from "@/hooks/useDefaultRobotData"; +import { RobotAnimationConfig } from "@/lib/types"; + +// Define the result interface for Urdf detection +interface UrdfDetectionResult { + hasUrdf: boolean; + modelName?: string; + parsedData?: UrdfData | null; +} + +// Define the context type +export type UrdfContextType = { + urdfProcessor: UrdfProcessor | null; + registerUrdfProcessor: (processor: UrdfProcessor) => void; + onUrdfDetected: ( + callback: (result: UrdfDetectionResult) => void + ) => () => void; + processUrdfFiles: ( + files: Record<string, File>, + availableModels: string[] + ) => Promise<void>; + urdfBlobUrls: Record<string, string>; + alternativeUrdfModels: string[]; + isSelectionModalOpen: boolean; + setIsSelectionModalOpen: (isOpen: boolean) => void; + urdfModelOptions: UrdfFileModel[]; + selectUrdfModel: (model: UrdfFileModel) => void; + + // Centralized robot data management + currentRobotData: UrdfData | null; + isDefaultModel: boolean; + setIsDefaultModel: (isDefault: boolean) => void; + resetToDefaultModel: () => void; + urdfContent: string | null; + + // Animation configuration management + currentAnimationConfig: RobotAnimationConfig | null; + setCurrentAnimationConfig: (config: RobotAnimationConfig | null) => void; + + // These properties are kept for backward compatibility but are considered + // implementation details and should not be used directly in components. + // TODO: Remove these next three once the time is right + parsedRobotData: UrdfData | null; // Data from parsed Urdf + customModelName: string; + customModelDescription: string; +}; + +// Create the context +export const UrdfContext = createContext<UrdfContextType | undefined>( + undefined +); + +// Props for the provider component +interface UrdfProviderProps { + children: ReactNode; +} + +export const UrdfProvider: React.FC<UrdfProviderProps> = ({ children }) => { + // State for Urdf processor + const [urdfProcessor, setUrdfProcessor] = useState<UrdfProcessor | null>( + null + ); + + // State for blob URLs (replacing window.urdfBlobUrls) + const [urdfBlobUrls, setUrdfBlobUrls] = useState<Record<string, string>>({}); + + // State for alternative models (replacing window.alternativeUrdfModels) + const [alternativeUrdfModels, setAlternativeUrdfModels] = useState<string[]>( + [] + ); + + // State for the Urdf selection modal + const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + const [urdfModelOptions, setUrdfModelOptions] = useState<UrdfFileModel[]>([]); + + // New state for centralized robot data management + const [isDefaultModel, setIsDefaultModel] = useState(true); + const [parsedRobotData, setParsedRobotData] = useState<UrdfData | null>(null); + const [customModelName, setCustomModelName] = useState<string>(""); + const [customModelDescription, setCustomModelDescription] = + useState<string>(""); + const [urdfContent, setUrdfContent] = useState<string | null>(null); + + // New state for animation configuration + const [currentAnimationConfig, setCurrentAnimationConfig] = + useState<RobotAnimationConfig | null>(null); + + // Get default robot data from our hook + const { data: defaultRobotData } = useDefaultRobotData("so101"); + + // Compute the current robot data based on model state + const currentRobotData = isDefaultModel ? defaultRobotData : parsedRobotData; + + // Fetch the default Urdf content when the component mounts + useEffect(() => { + // Only fetch if we don't have content and we're using the default model + if (isDefaultModel && !urdfContent) { + const fetchDefaultUrdf = async () => { + try { + // Path to the default T12 Urdf file + const defaultUrdfPath = "/so-101-urdf/urdf/so101_new_calib.urdf"; + + // Fetch the Urdf content + const response = await fetch(defaultUrdfPath); + + if (!response.ok) { + throw new Error( + `Failed to fetch default Urdf: ${response.statusText}` + ); + } + + const defaultUrdfContent = await response.text(); + console.log( + `📄 Default Urdf content loaded, length: ${defaultUrdfContent.length} characters` + ); + + // Set the Urdf content in state + setUrdfContent(defaultUrdfContent); + } catch (error) { + console.error("❌ Error loading default Urdf content:", error); + } + }; + + fetchDefaultUrdf(); + } + }, [isDefaultModel, urdfContent]); + + // Log data state changes for debugging + useEffect(() => { + console.log("🤖 Robot data context updated:", { + isDefaultModel, + hasDefaultData: !!defaultRobotData, + hasParsedData: !!parsedRobotData, + currentData: currentRobotData ? "available" : "null", + }); + }, [isDefaultModel, defaultRobotData, parsedRobotData, currentRobotData]); + + // Reference for callbacks + const urdfCallbacksRef = useRef<((result: UrdfDetectionResult) => void)[]>( + [] + ); + + // Reset to default model + const resetToDefaultModel = useCallback(() => { + setIsDefaultModel(true); + setCustomModelName(""); + setCustomModelDescription(""); + setParsedRobotData(null); + setUrdfContent(null); + setCurrentAnimationConfig(null); + + toast.info("Switched to default model", { + description: "The default ARM100 robot model is now displayed.", + }); + }, []); + + // Register a callback for Urdf detection + const onUrdfDetected = useCallback( + (callback: (result: UrdfDetectionResult) => void) => { + urdfCallbacksRef.current.push(callback); + + return () => { + urdfCallbacksRef.current = urdfCallbacksRef.current.filter( + (cb) => cb !== callback + ); + }; + }, + [] + ); + + // Register a Urdf processor + const registerUrdfProcessor = useCallback((processor: UrdfProcessor) => { + setUrdfProcessor(processor); + }, []); + + // Internal function to notify callbacks and update central state + const notifyUrdfCallbacks = useCallback( + (result: UrdfDetectionResult) => { + console.log("📣 Notifying Urdf callbacks with result:", result); + + // Update our internal state based on the result + if (result.hasUrdf) { + // Always ensure we set isDefaultModel to false when we have a Urdf + setIsDefaultModel(false); + + if (result.parsedData) { + // Create a copy of the parsed data with any missing fields filled from our state + const enhancedParsedData: UrdfData = { + ...result.parsedData, + }; + + // Set the name if available, or use the provided modelName as fallback + if (result.parsedData.name) { + setCustomModelName(result.parsedData.name); + } else if (result.modelName) { + setCustomModelName(result.modelName); + // Also update the parsed data with this name to be consistent + enhancedParsedData.name = result.modelName; + } + + // Set description if available + if (result.parsedData.description) { + setCustomModelDescription(result.parsedData.description); + } else { + // If no description in parsed data, set a default one + const defaultDesc = + "A detailed 3D model of a robotic system with articulated joints and components."; + enhancedParsedData.description = defaultDesc; + setCustomModelDescription(defaultDesc); + } + + // Update parsed data with the enhanced version + setParsedRobotData(enhancedParsedData); + } else if (result.modelName) { + // Only have model name, no parsed data + setCustomModelName(result.modelName); + + // Create a minimal UrdfData object with at least the name + const minimalData: UrdfData = { + name: result.modelName, + description: + "A detailed 3D model of a robotic system with articulated joints and components.", + }; + setParsedRobotData(minimalData); + } + } else { + // If no Urdf, reset to default + resetToDefaultModel(); + } + + // Call all registered callbacks + urdfCallbacksRef.current.forEach((callback) => callback(result)); + }, + [resetToDefaultModel] + ); + + // Helper function to process the selected Urdf model + const processSelectedUrdf = useCallback( + async (model: UrdfFileModel) => { + if (!urdfProcessor) return; + + // Find the file in our files record + const files = Object.values(urdfBlobUrls) + .filter((url) => url === model.blobUrl) + .map((url) => { + const path = Object.keys(urdfBlobUrls).find( + (key) => urdfBlobUrls[key] === url + ); + return path ? { path, url } : null; + }) + .filter((item) => item !== null); + + if (files.length === 0) { + console.error("❌ Could not find file for selected Urdf model"); + return; + } + + // Show a toast notification that we're loading the Urdf + const loadingToast = toast.loading("Loading Urdf model...", { + description: "Preparing 3D visualization", + duration: 5000, + }); + + try { + // Get the file from our record + const filePath = files[0]?.path; + if (!filePath || !urdfBlobUrls[filePath]) { + throw new Error("File not found in records"); + } + + // Get the actual File object + const response = await fetch(model.blobUrl); + const blob = await response.blob(); + const file = new File( + [blob], + filePath.split("/").pop() || "model.urdf", + { + type: "application/xml", + } + ); + + // Read the Urdf content + const urdfContent = await readUrdfFileContent(file); + + console.log( + `📏 Urdf content read, length: ${urdfContent.length} characters` + ); + + // Store the Urdf content in state + setUrdfContent(urdfContent); + + // Dismiss the toast + toast.dismiss(loadingToast); + + // Always set isDefaultModel to false when processing a custom Urdf + setIsDefaultModel(false); + + // Success case - create basic model data + const modelDisplayName = + model.name || model.path.split("/").pop() || "Unknown"; + + // Create basic data structure with name and description + const basicData: UrdfData = { + name: modelDisplayName, + description: + "A detailed 3D model of a robotic system with articulated joints and components.", + }; + + // Update our state + setCustomModelName(modelDisplayName); + setCustomModelDescription(basicData.description); + setParsedRobotData(basicData); + + toast.success("Urdf model loaded successfully", { + description: `Model: ${modelDisplayName}`, + duration: 3000, + }); + + // Notify callbacks with the basic data + notifyUrdfCallbacks({ + hasUrdf: true, + modelName: modelDisplayName, + parsedData: basicData, + }); + } catch (error) { + // Error case + console.error("❌ Error processing selected Urdf:", error); + toast.dismiss(loadingToast); + toast.error("Error loading Urdf", { + description: `Error: ${ + error instanceof Error ? error.message : String(error) + }`, + duration: 3000, + }); + + // Keep showing the custom model even if loading failed + // No need to reset to default unless user explicitly chooses to + } + }, + [urdfBlobUrls, urdfProcessor, notifyUrdfCallbacks] + ); + + // Function to handle selecting a Urdf model from the modal + const selectUrdfModel = useCallback( + (model: UrdfFileModel) => { + if (!urdfProcessor) { + console.error("❌ No Urdf processor available"); + return; + } + + console.log(`🤖 Selected model: ${model.name || model.path}`); + + // Close the modal + setIsSelectionModalOpen(false); + + // Extract model name + const modelName = + model.name || + model.path + .split("/") + .pop() + ?.replace(/\.urdf$/i, "") || + "Unknown"; + + // Load the selected Urdf model + urdfProcessor.loadUrdf(model.blobUrl); + + // Update our state immediately even before parsing + setIsDefaultModel(false); + setCustomModelName(modelName); + + // Show a toast notification that we're loading the model + toast.info(`Loading model: ${modelName}`, { + description: "Preparing 3D visualization", + duration: 2000, + }); + + // Notify callbacks about the selection before parsing + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + parsedData: undefined, // Will use parseUrdf later to get the data + }); + + // Try to parse the model - this will update the UI when complete + processSelectedUrdf(model); + }, + [urdfProcessor, notifyUrdfCallbacks, processSelectedUrdf] + ); + + // Process Urdf files - moved from DragAndDropContext + const processUrdfFiles = useCallback( + async (files: Record<string, File>, availableModels: string[]) => { + // Clear previous blob URLs to prevent memory leaks + Object.values(urdfBlobUrls).forEach(URL.revokeObjectURL); + setUrdfBlobUrls({}); + setAlternativeUrdfModels([]); + setUrdfModelOptions([]); + + try { + // Check if we have any Urdf files + if (availableModels.length > 0 && urdfProcessor) { + console.log( + `🤖 Found ${availableModels.length} Urdf models:`, + availableModels + ); + + // Create blob URLs for all models + const newUrdfBlobUrls: Record<string, string> = {}; + availableModels.forEach((path) => { + if (files[path]) { + newUrdfBlobUrls[path] = URL.createObjectURL(files[path]); + } + }); + setUrdfBlobUrls(newUrdfBlobUrls); + + // Save alternative models for reference + setAlternativeUrdfModels(availableModels); + + // Create model options for the selection modal + const modelOptions: UrdfFileModel[] = availableModels.map((path) => { + const fileName = path.split("/").pop() || ""; + const modelName = fileName.replace(/\.urdf$/i, ""); + return { + path, + blobUrl: newUrdfBlobUrls[path], + name: modelName, + }; + }); + + setUrdfModelOptions(modelOptions); + + // If there's only one model, use it directly + if (availableModels.length === 1) { + // Extract model name from the Urdf file + const fileName = availableModels[0].split("/").pop() || ""; + const modelName = fileName.replace(/\.urdf$/i, ""); + console.log(`📄 Using model: ${modelName} (${fileName})`); + + // Use the blob URL instead of the file path + const blobUrl = newUrdfBlobUrls[availableModels[0]]; + if (blobUrl) { + console.log(`🔗 Using blob URL for Urdf: ${blobUrl}`); + urdfProcessor.loadUrdf(blobUrl); + + // Immediately update model state + setIsDefaultModel(false); + setCustomModelName(modelName); + + // Process the Urdf file for content storage + if (files[availableModels[0]]) { + console.log("📄 Reading Urdf content..."); + + // Show a toast notification that we're loading the Urdf + const loadingToast = toast.loading("Loading Urdf model...", { + description: "Preparing 3D visualization", + duration: 5000, + }); + + try { + const urdfContent = await readUrdfFileContent( + files[availableModels[0]] + ); + + console.log( + `📏 Urdf content read, length: ${urdfContent.length} characters` + ); + + // Store the Urdf content in state + setUrdfContent(urdfContent); + + // Dismiss the loading toast + toast.dismiss(loadingToast); + + toast.success("Urdf model loaded successfully", { + description: `Model: ${modelName}`, + duration: 3000, + }); + + // Create basic data structure with name and description + const basicData: UrdfData = { + name: modelName, + description: + "A detailed 3D model of a robotic system with articulated joints and components.", + }; + + // Update our state + setCustomModelDescription(basicData.description); + setParsedRobotData(basicData); + + // Notify callbacks with all the information + notifyUrdfCallbacks({ + hasUrdf: true, + modelName: modelName, + parsedData: basicData, + }); + } catch (loadError) { + console.error("❌ Error loading Urdf:", loadError); + toast.dismiss(loadingToast); + toast.error("Error loading Urdf", { + description: `Error: ${ + loadError instanceof Error + ? loadError.message + : String(loadError) + }`, + duration: 3000, + }); + + // Still notify callbacks without detailed data + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + } + } else { + console.error( + "❌ Could not find file for Urdf model:", + availableModels[0] + ); + console.log("📦 Available files:", Object.keys(files)); + + // Still notify callbacks without detailed data + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + } + } else { + console.warn( + `⚠️ No blob URL found for ${availableModels[0]}, using path directly` + ); + urdfProcessor.loadUrdf(availableModels[0]); + + // Update the state even without a blob URL + setIsDefaultModel(false); + setCustomModelName(modelName); + + // Notify callbacks + notifyUrdfCallbacks({ + hasUrdf: true, + modelName, + }); + } + } else { + // Multiple Urdf files found, show selection modal + console.log( + "📋 Multiple Urdf files found, showing selection modal" + ); + setIsSelectionModalOpen(true); + + // Notify that Urdf files are available but selection is needed + notifyUrdfCallbacks({ + hasUrdf: true, + modelName: "Multiple models available", + }); + } + } else { + console.warn( + "❌ No Urdf models found in dropped files or no processor available" + ); + notifyUrdfCallbacks({ hasUrdf: false, parsedData: null }); + + // Reset to default model when no Urdf files are found + resetToDefaultModel(); + + toast.error("No Urdf file found", { + description: "Please upload a folder containing a .urdf file.", + duration: 3000, + }); + } + } catch (error) { + console.error("❌ Error processing Urdf files:", error); + toast.error("Error processing files", { + description: `Error: ${ + error instanceof Error ? error.message : String(error) + }`, + duration: 3000, + }); + + // Reset to default model on error + resetToDefaultModel(); + } + }, + [notifyUrdfCallbacks, urdfBlobUrls, urdfProcessor, resetToDefaultModel] + ); + + // Clean up blob URLs when component unmounts + React.useEffect(() => { + return () => { + Object.values(urdfBlobUrls).forEach(URL.revokeObjectURL); + }; + }, [urdfBlobUrls]); + + // Create the context value + const contextValue: UrdfContextType = { + urdfProcessor, + registerUrdfProcessor, + onUrdfDetected, + processUrdfFiles, + urdfBlobUrls, + alternativeUrdfModels, + isSelectionModalOpen, + setIsSelectionModalOpen, + urdfModelOptions, + selectUrdfModel, + + // New properties for centralized robot data management + currentRobotData, + isDefaultModel, + setIsDefaultModel, + parsedRobotData, + customModelName, + customModelDescription, + resetToDefaultModel, + urdfContent, + + // Animation configuration management + currentAnimationConfig, + setCurrentAnimationConfig, + }; + + return ( + <UrdfContext.Provider value={contextValue}>{children}</UrdfContext.Provider> + ); +}; diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b0fe1dfef3b17850bbac040665f514a8ffd0f15 --- /dev/null +++ b/src/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c14125ac48b874740770da80d9e424daf1db58b --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,191 @@ +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial<ToasterToast> + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit<ToasterToast, "id"> + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState<State>(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/hooks/useDefaultRobotData.ts b/src/hooks/useDefaultRobotData.ts new file mode 100644 index 0000000000000000000000000000000000000000..a82704167443a964e8dbc0854ca61c920f035f46 --- /dev/null +++ b/src/hooks/useDefaultRobotData.ts @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { UrdfData } from "@/lib/types"; + +/** + * Fetches default robot data from a JSON file + * @param robotName The name of the robot folder (e.g., 'T12') + */ +async function fetchRobotData(robotName: string): Promise<UrdfData> { + const response = await fetch(`/so-101-urdf/urdf/so101_new_calib.urdf`); + + if (!response.ok) { + throw new Error(`Failed to fetch default robot data for ${robotName}`); + } + + return await response.json(); +} + +/** + * Hook to fetch default robot model data from a JSON file using React Query + * @param robotName The name of the robot folder (e.g., 'T12') + * @returns The robot data query result + */ +export function useDefaultRobotData(robotName: string = "so101") { + return useQuery({ + queryKey: ["defaultRobotData", robotName], + queryFn: () => fetchRobotData(robotName), + staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes + retry: 2, // Retry failed requests twice + }); +} + +/** + * Function to load default robot data for use outside React components + * @param robotName The name of the robot folder (e.g., 'T12') + * @returns A promise that resolves to the robot data or null if loading fails + */ +export async function loadDefaultRobotData( + robotName: string = "so101" +): Promise<UrdfData | null> { + try { + return await fetchRobotData(robotName); + } catch (err) { + console.error(`Error loading default robot data for ${robotName}:`, err); + return null; + } +} + +export default useDefaultRobotData; diff --git a/src/hooks/useDragAndDrop.tsx b/src/hooks/useDragAndDrop.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4d7275aa8d50c825e438e254c6cce1b502bbd830 --- /dev/null +++ b/src/hooks/useDragAndDrop.tsx @@ -0,0 +1,14 @@ +import { + DragAndDropContextType, + DragAndDropContext, +} from "@/contexts/DragAndDropContext"; +import { useContext } from "react"; + +// Custom hook to use the DragAndDrop context +export const useDragAndDrop = (): DragAndDropContextType => { + const context = useContext(DragAndDropContext); + if (context === undefined) { + throw new Error("useDragAndDrop must be used within a DragAndDropProvider"); + } + return context; +}; diff --git a/src/hooks/useRealTimeJoints.ts b/src/hooks/useRealTimeJoints.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a1522714f9a47425536aedc0632ca3ebc0a8fab --- /dev/null +++ b/src/hooks/useRealTimeJoints.ts @@ -0,0 +1,184 @@ +import { useEffect, useRef, useCallback } from "react"; +import { URDFViewerElement } from "@/lib/urdfViewerHelpers"; + +interface JointData { + type: "joint_update"; + joints: Record<string, number>; + timestamp: number; +} + +interface UseRealTimeJointsProps { + viewerRef: React.RefObject<URDFViewerElement>; + enabled?: boolean; + websocketUrl?: string; +} + +export const useRealTimeJoints = ({ + viewerRef, + enabled = true, + websocketUrl = "ws://localhost:8000/ws/joint-data", +}: UseRealTimeJointsProps) => { + const wsRef = useRef<WebSocket | null>(null); + const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); + const isConnectedRef = useRef<boolean>(false); + + const updateJointValues = useCallback( + (joints: Record<string, number>) => { + const viewer = viewerRef.current; + if (!viewer || typeof viewer.setJointValue !== "function") { + return; + } + + // Update each joint value in the URDF viewer + Object.entries(joints).forEach(([jointName, value]) => { + try { + viewer.setJointValue(jointName, value); + } catch (error) { + console.warn(`Failed to set joint ${jointName}:`, error); + } + }); + }, + [viewerRef] + ); + + const connectWebSocket = useCallback(() => { + if (!enabled) return; + + // First, test if the server is running + const testServerConnection = async () => { + try { + const response = await fetch("http://localhost:8000/health"); + if (!response.ok) { + console.error("❌ Server health check failed:", response.status); + return false; + } + const data = await response.json(); + console.log("✅ Server is running:", data); + return true; + } catch (error) { + console.error("❌ Server is not reachable:", error); + return false; + } + }; + + // Test server connection first + testServerConnection().then((serverAvailable) => { + if (!serverAvailable) { + console.error("❌ Cannot connect to WebSocket: Server is not running"); + console.log( + "💡 Make sure to start the FastAPI server with: python -m uvicorn lerobot.livelab.app.main:app --reload" + ); + return; + } + + try { + console.log("🔗 Connecting to WebSocket:", websocketUrl); + + const ws = new WebSocket(websocketUrl); + wsRef.current = ws; + + ws.onopen = () => { + console.log("✅ WebSocket connected for real-time joints"); + isConnectedRef.current = true; + + // Clear any existing reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + + ws.onmessage = (event) => { + try { + const data: JointData = JSON.parse(event.data); + + if (data.type === "joint_update" && data.joints) { + updateJointValues(data.joints); + } + } catch (error) { + console.error("❌ Error parsing WebSocket message:", error); + } + }; + + ws.onclose = (event) => { + console.log( + "🔌 WebSocket connection closed:", + event.code, + event.reason + ); + isConnectedRef.current = false; + wsRef.current = null; + + // Provide more specific error information + if (event.code === 1006) { + console.error( + "❌ WebSocket connection failed - server may not be running or endpoint not found" + ); + } else if (event.code === 1000) { + console.log("✅ WebSocket closed normally"); + } + + // Attempt to reconnect after a delay if enabled + if (enabled && !reconnectTimeoutRef.current && event.code !== 1000) { + reconnectTimeoutRef.current = setTimeout(() => { + console.log("🔄 Attempting to reconnect WebSocket..."); + connectWebSocket(); + }, 3000); // Reconnect after 3 seconds + } + }; + + ws.onerror = (error) => { + console.error("❌ WebSocket error:", error); + console.log("💡 Troubleshooting tips:"); + console.log( + " 1. Make sure FastAPI server is running on localhost:8000" + ); + console.log(" 2. Check if the /ws/joint-data endpoint exists"); + console.log( + " 3. Restart the server if you just added WebSocket support" + ); + isConnectedRef.current = false; + }; + } catch (error) { + console.error("❌ Failed to create WebSocket connection:", error); + } + }); + }, [enabled, websocketUrl, updateJointValues]); + + const disconnect = useCallback(() => { + // Clear reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + // Close WebSocket connection + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + + isConnectedRef.current = false; + }, []); + + // Effect to manage WebSocket connection + useEffect(() => { + if (enabled) { + connectWebSocket(); + } else { + disconnect(); + } + + // Cleanup on unmount + return () => { + disconnect(); + }; + }, [enabled, connectWebSocket, disconnect]); + + // Return connection status and control functions + return { + isConnected: isConnectedRef.current, + disconnect, + reconnect: connectWebSocket, + }; +}; diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d5712294e3c538c61e2e324e3384686222f34226 --- /dev/null +++ b/src/hooks/useTheme.tsx @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { + ThemeProviderContext, + ThemeProviderState, +} from "../contexts/ThemeContext"; + +export const useTheme = (): ThemeProviderState => { + const context = useContext(ThemeProviderContext); + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider"); + + return context; +}; diff --git a/src/hooks/useUrdf.ts b/src/hooks/useUrdf.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c4b2f95c14ef3174debe7830ffe0c6a168539e2 --- /dev/null +++ b/src/hooks/useUrdf.ts @@ -0,0 +1,11 @@ +import { UrdfContextType, UrdfContext } from "@/contexts/UrdfContext"; +import { useContext } from "react"; + +// Custom hook to use the Urdf context +export const useUrdf = (): UrdfContextType => { + const context = useContext(UrdfContext); + if (context === undefined) { + throw new Error("useUrdf must be used within a UrdfProvider"); + } + return context; +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..a20aa5880822e15a7a8983c10fc9633a5314eed8 --- /dev/null +++ b/src/index.css @@ -0,0 +1,103 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Definition of the design system. All colors, gradients, fonts, etc should be defined here. */ + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + + --sidebar-background: 0 0% 98%; + + --sidebar-foreground: 240 5.3% 26.1%; + + --sidebar-primary: 240 5.9% 10%; + + --sidebar-primary-foreground: 0 0% 98%; + + --sidebar-accent: 240 4.8% 95.9%; + + --sidebar-accent-foreground: 240 5.9% 10%; + + --sidebar-border: 220 13% 91%; + + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/src/lib/UrdfDragAndDrop.ts b/src/lib/UrdfDragAndDrop.ts new file mode 100644 index 0000000000000000000000000000000000000000..e39194bd5aa72b1af01fd21073724c9db2d2492e --- /dev/null +++ b/src/lib/UrdfDragAndDrop.ts @@ -0,0 +1,415 @@ +/** + * Urdf Drag and Drop Utility + * + * This file provides functionality for handling drag and drop of Urdf folders. + * It converts the dropped files into accessible blobs for visualization. + */ + +/** + * Converts a DataTransfer structure into an object with all paths and files. + * @param dataTransfer The DataTransfer object from the drop event + * @returns A promise that resolves with the file structure object + */ +export function dataTransferToFiles( + dataTransfer: DataTransfer +): Promise<Record<string, File>> { + if (!(dataTransfer instanceof DataTransfer)) { + throw new Error('Data must be of type "DataTransfer"'); + } + + const files: Record<string, File> = {}; + + /** + * Recursively processes a directory entry to extract all files + * Using type 'unknown' and then type checking for safety with WebKit's non-standard API + */ + function recurseDirectory(item: unknown): Promise<void> { + // Type guard for file entries + const isFileEntry = ( + entry: unknown + ): entry is { + isFile: boolean; + fullPath: string; + file: (callback: (file: File) => void) => void; + } => + entry !== null && + typeof entry === "object" && + "isFile" in entry && + typeof (entry as Record<string, unknown>).file === "function" && + "fullPath" in entry; + + // Type guard for directory entries + const isDirEntry = ( + entry: unknown + ): entry is { + isFile: boolean; + createReader: () => { + readEntries: (callback: (entries: unknown[]) => void) => void; + }; + } => + entry !== null && + typeof entry === "object" && + "isFile" in entry && + typeof (entry as Record<string, unknown>).createReader === "function"; + + if (isFileEntry(item) && item.isFile) { + return new Promise((resolve) => { + item.file((file: File) => { + files[item.fullPath] = file; + resolve(); + }); + }); + } else if (isDirEntry(item) && !item.isFile) { + const reader = item.createReader(); + + return new Promise((resolve) => { + const promises: Promise<void>[] = []; + + // Exhaustively read all directory entries + function readNextEntries() { + reader.readEntries((entries: unknown[]) => { + if (entries.length === 0) { + Promise.all(promises).then(() => resolve()); + } else { + entries.forEach((entry) => { + promises.push(recurseDirectory(entry)); + }); + readNextEntries(); + } + }); + } + + readNextEntries(); + }); + } + + return Promise.resolve(); + } + + return new Promise((resolve) => { + // Process dropped items + const dtitems = dataTransfer.items && Array.from(dataTransfer.items); + const dtfiles = Array.from(dataTransfer.files); + + if (dtitems && dtitems.length && "webkitGetAsEntry" in dtitems[0]) { + const promises: Promise<void>[] = []; + + for (let i = 0; i < dtitems.length; i++) { + const item = dtitems[i] as unknown as { + webkitGetAsEntry: () => unknown; + }; + + if (typeof item.webkitGetAsEntry === "function") { + const entry = item.webkitGetAsEntry(); + if (entry) { + promises.push(recurseDirectory(entry)); + } + } + } + + Promise.all(promises).then(() => resolve(files)); + } else { + // Add a '/' prefix to match the file directory entry on webkit browsers + dtfiles + .filter((f) => f.size !== 0) + .forEach((f) => (files["/" + f.name] = f)); + + resolve(files); + } + }); +} + +/** + * Cleans a file path by removing '..' and '.' tokens and normalizing slashes + */ +export function cleanFilePath(path: string): string { + return path + .replace(/\\/g, "/") + .split(/\//g) + .reduce((acc, el) => { + if (el === "..") acc.pop(); + else if (el !== ".") acc.push(el); + return acc; + }, [] as string[]) + .join("/"); +} + +/** + * Interface representing the structure of an Urdf processor + */ +export interface UrdfProcessor { + loadUrdf: (path: string) => void; + setUrlModifierFunc: (func: (url: string) => string) => void; + getPackage: () => string; +} + +// Reference to hold the package path +const packageRef = { current: "" }; + +/** + * Reads the content of a Urdf file + * @param file The Urdf file object + * @returns A promise that resolves with the content of the file as a string + */ +export function readUrdfFileContent(file: File): Promise<string> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target && event.target.result) { + resolve(event.target.result as string); + } else { + reject(new Error("Failed to read Urdf file content")); + } + }; + reader.onerror = () => reject(new Error("Error reading Urdf file")); + reader.readAsText(file); + }); +} + +/** + * Downloads a zip file from a URL and extracts its contents + * @param zipUrl URL of the zip file to download + * @param urdfProcessor The Urdf processor to use for loading + * @returns A promise that resolves with the extraction results + */ +export async function downloadAndExtractZip( + zipUrl: string, + urdfProcessor: UrdfProcessor +): Promise<{ + files: Record<string, File>; + availableModels: string[]; + blobUrls: Record<string, string>; +}> { + console.log("🔄 Downloading zip file from:", zipUrl); + + try { + // Download the zip file + const response = await fetch(zipUrl); + if (!response.ok) { + throw new Error(`Failed to download zip: ${response.statusText}`); + } + + const zipBlob = await response.blob(); + + // Load JSZip dynamically since it's much easier to work with than manual Blob handling + // We use dynamic import to avoid adding a dependency + const JSZip = (await import("jszip")).default; + const zip = new JSZip(); + + // Load the zip content + const contents = await zip.loadAsync(zipBlob); + + // Convert zip contents to files + const files: Record<string, File> = {}; + const filePromises: Promise<void>[] = []; + + // Process each file in the zip + contents.forEach((relativePath, zipEntry) => { + if (!zipEntry.dir) { + const promise = zipEntry.async("blob").then((blob) => { + // Create a file with the proper name and path + const path = "/" + relativePath; + files[path] = new File( + [blob], + relativePath.split("/").pop() || "unknown", + { + type: getMimeType(relativePath.split(".").pop() || ""), + } + ); + }); + filePromises.push(promise); + } + }); + + // Wait for all files to be processed + await Promise.all(filePromises); + + // Get all file paths and clean them + const fileNames = Object.keys(files).map((n) => cleanFilePath(n)); + + // Filter all files ending in Urdf + const availableModels = fileNames.filter((n) => /urdf$/i.test(n)); + + // Create blob URLs for Urdf files + const blobUrls: Record<string, string> = {}; + availableModels.forEach((path) => { + blobUrls[path] = URL.createObjectURL(files[path]); + }); + + // Extract the package base path from the first Urdf model for reference + let packageBasePath = ""; + if (availableModels.length > 0) { + // Extract the main directory path (e.g., '/cassie_description/') + const firstModel = availableModels[0]; + const packageMatch = firstModel.match(/^(\/[^/]+\/)/); + if (packageMatch && packageMatch[1]) { + packageBasePath = packageMatch[1]; + } + } + + // Store the package path for future reference + const packagePathRef = packageBasePath; + urdfProcessor.setUrlModifierFunc((url) => { + // Find the matching file given the requested URL + + // Store package reference for future use + if (packagePathRef) { + packageRef.current = packagePathRef; + } + + // Simple approach: just find the first file that matches the end of the URL + const cleaned = cleanFilePath(url); + + // Get the filename from the URL + const urlFilename = cleaned.split("/").pop() || ""; + + // Find the first file that ends with this filename + let fileName = fileNames.find((name) => name.endsWith(urlFilename)); + + // If no match found, just take the first file with a similar extension + if (!fileName && urlFilename.includes(".")) { + const extension = "." + urlFilename.split(".").pop(); + fileName = fileNames.find((name) => name.endsWith(extension)); + } + + if (fileName !== undefined && fileName !== null) { + // Extract file extension for content type + const fileExtension = fileName.split(".").pop()?.toLowerCase() || ""; + + // Create blob URL with extension in the searchParams to help with format detection + const blob = new Blob([files[fileName]], { + type: getMimeType(fileExtension), + }); + const blobUrl = URL.createObjectURL(blob) + "#." + fileExtension; + + // Don't revoke immediately, wait for the mesh to be loaded + setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); + return blobUrl; + } + + console.warn(`No matching file found for: ${url}`); + return url; + }); + + return { + files, + availableModels, + blobUrls, + }; + } catch (error) { + console.error("❌ Error downloading or extracting zip:", error); + throw error; + } +} + +/** + * Processes dropped files and returns information about available Urdf models + */ +export async function processDroppedFiles( + dataTransfer: DataTransfer, + urdfProcessor: UrdfProcessor +): Promise<{ + files: Record<string, File>; + availableModels: string[]; + blobUrls: Record<string, string>; +}> { + // Reset the package reference + packageRef.current = ""; + + // Convert dropped files into a structured format + const files = await dataTransferToFiles(dataTransfer); + + // Get all file paths and clean them + const fileNames = Object.keys(files).map((n) => cleanFilePath(n)); + + // Filter all files ending in Urdf + const availableModels = fileNames.filter((n) => /urdf$/i.test(n)); + + // Create blob URLs for Urdf files + const blobUrls: Record<string, string> = {}; + availableModels.forEach((path) => { + blobUrls[path] = URL.createObjectURL(files[path]); + }); + + // Extract the package base path from the first Urdf model for reference + let packageBasePath = ""; + if (availableModels.length > 0) { + // Extract the main directory path (e.g., '/cassie_description/') + const firstModel = availableModels[0]; + const packageMatch = firstModel.match(/^(\/[^/]+\/)/); + if (packageMatch && packageMatch[1]) { + packageBasePath = packageMatch[1]; + } + } + + // Store the package path for future reference + const packagePathRef = packageBasePath; + urdfProcessor.setUrlModifierFunc((url) => { + // Find the matching file given the requested URL + + // Store package reference for future use + if (packagePathRef) { + packageRef.current = packagePathRef; + } + + // Simple approach: just find the first file that matches the end of the URL + const cleaned = cleanFilePath(url); + + // Get the filename from the URL + const urlFilename = cleaned.split("/").pop() || ""; + + // Find the first file that ends with this filename + let fileName = fileNames.find((name) => name.endsWith(urlFilename)); + + // If no match found, just take the first file with a similar extension + if (!fileName && urlFilename.includes(".")) { + const extension = "." + urlFilename.split(".").pop(); + fileName = fileNames.find((name) => name.endsWith(extension)); + } + + if (fileName !== undefined && fileName !== null) { + // Extract file extension for content type + const fileExtension = fileName.split(".").pop()?.toLowerCase() || ""; + + // Create blob URL with extension in the searchParams to help with format detection + const blob = new Blob([files[fileName]], { + type: getMimeType(fileExtension), + }); + const blobUrl = URL.createObjectURL(blob) + "#." + fileExtension; + + // Don't revoke immediately, wait for the mesh to be loaded + setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); + return blobUrl; + } + + console.warn(`No matching file found for: ${url}`); + return url; + }); + + return { + files, + availableModels, + blobUrls, + }; +} + +/** + * Get the MIME type for a file extension + */ +function getMimeType(extension: string): string { + switch (extension.toLowerCase()) { + case "stl": + return "model/stl"; + case "obj": + return "model/obj"; + case "gltf": + case "glb": + return "model/gltf+json"; + case "dae": + return "model/vnd.collada+xml"; + case "urdf": + return "application/xml"; + default: + return "application/octet-stream"; + } +} diff --git a/src/lib/meshLoaders.ts b/src/lib/meshLoaders.ts new file mode 100644 index 0000000000000000000000000000000000000000..6361f8a63d26cf9afbc783dd6c547f7beb397f7b --- /dev/null +++ b/src/lib/meshLoaders.ts @@ -0,0 +1,111 @@ +import { + LoadingManager, + MeshPhongMaterial, + Mesh, + Color, + Object3D, + Group, + BoxGeometry, +} from "three"; +import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js"; +import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js"; +import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js"; + +/** + * Loads mesh files of different formats + * @param path The path to the mesh file + * @param manager The THREE.js loading manager + * @param done Callback function when loading is complete + */ +export const loadMeshFile = ( + path: string, + manager: LoadingManager, + done: (result: Object3D | Group | Mesh | null, err?: Error) => void +) => { + // First try to get extension from the original path + let ext = path.split(/\./g).pop()?.toLowerCase(); + + // If the URL is a blob URL with a fragment containing the extension, use that + if (path.startsWith("blob:") && path.includes("#.")) { + const fragmentExt = path.split("#.").pop(); + if (fragmentExt) { + ext = fragmentExt.toLowerCase(); + } + } + + // If we can't determine extension, try to check Content-Type + if (!ext) { + console.error(`Could not determine file extension for: ${path}`); + done(null, new Error(`Unsupported file format: ${path}`)); + return; + } + + switch (ext) { + case "gltf": + case "glb": + new GLTFLoader(manager).load( + path, + (result) => done(result.scene), + null, + (err) => done(null, err as Error) + ); + break; + case "obj": + new OBJLoader(manager).load( + path, + (result) => done(result), + null, + (err) => done(null, err as Error) + ); + break; + case "dae": + new ColladaLoader(manager).load( + path, + (result) => done(result.scene), + null, + (err) => done(null, err as Error) + ); + break; + case "stl": + console.log(`🔧 Loading STL file: ${path}`); + new STLLoader(manager).load( + path, + (result) => { + console.log(`✅ STL loaded successfully: ${path}`); + const material = new MeshPhongMaterial(); + const mesh = new Mesh(result, material); + done(mesh); + }, + (progress) => { + console.log(`📊 STL loading progress: ${path}`, progress); + }, + (err) => { + console.error(`❌ STL loading failed: ${path}`, err); + + // Create a fallback basic geometry when STL fails to load + console.log(`🔄 Creating fallback geometry for: ${path}`); + const fallbackGeometry = new BoxGeometry(0.05, 0.05, 0.05); // Small 5cm cube + const fallbackMaterial = new MeshPhongMaterial({ + color: 0xff6b35, // Orange color to indicate it's a fallback + transparent: true, + opacity: 0.7, + }); + const fallbackMesh = new Mesh(fallbackGeometry, fallbackMaterial); + done(fallbackMesh); + } + ); + break; + default: + done(null, new Error(`Unsupported file format: ${ext}`)); + } +}; + +/** + * Creates a color in THREE.js format from a CSS color string + * @param color The CSS color string + * @returns A THREE.js Color + */ +export const createColor = (color: string): Color => { + return new Color(color); +}; diff --git a/src/lib/mockData.ts b/src/lib/mockData.ts new file mode 100644 index 0000000000000000000000000000000000000000..e067f4316133b9145b01fbece7e8593e4de7b045 --- /dev/null +++ b/src/lib/mockData.ts @@ -0,0 +1,26 @@ + +// Generate mock sensor data +export const generateSensorData = () => { + const time = Date.now(); + return { + time: time % 10000, + sensor1: Math.sin(time / 1000) * 50 + 50, + sensor2: Math.cos(time / 1500) * 30 + 70, + sensor3: Math.sin(time / 800) * 40 + 60, + sensor4: Math.cos(time / 1200) * 35 + 65, + }; +}; + +// Generate mock motor data +export const generateMotorData = () => { + const time = Date.now(); + return { + time: time % 10000, + motor1: Math.sin(time / 1000) * 20 + 30, + motor2: Math.cos(time / 1200) * 25 + 45, + motor3: Math.sin(time / 900) * 30 + 50, + motor4: Math.cos(time / 1100) * 22 + 35, + motor5: Math.sin(time / 1300) * 28 + 40, + motor6: Math.cos(time / 1400) * 26 + 42, + }; +}; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3f9d67a7956896b5139801aaed16957fe8cb7d5 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,97 @@ +/** + * Shared type definitions for Urdf parsing from supabase edge function + */ + +export interface UrdfData { + name?: string; + description?: string; + mass?: number; + dofs?: number; + joints?: { + revolute?: number; + prismatic?: number; + continuous?: number; + fixed?: number; + other?: number; + }; + links?: { + name?: string; + mass?: number; + }[]; + materials?: { + name?: string; + percentage?: number; + }[]; +} + +/** + * Interface representing a Urdf file model + */ +export interface UrdfFileModel { + /** + * Path to the Urdf file + */ + path: string; + + /** + * Blob URL for accessing the file + */ + blobUrl: string; + + /** + * Name of the model extracted from the file path + */ + name?: string; +} + +/** + * Joint animation configuration interface + */ +export interface JointAnimationConfig { + /** Joint name in the Urdf */ + name: string; + /** Animation type (sine, linear, etc.) */ + type: "sine" | "linear" | "constant"; + /** Minimum value for the joint */ + min: number; + /** Maximum value for the joint */ + max: number; + /** Speed multiplier for the animation (lower = slower) */ + speed: number; + /** Phase offset in radians */ + offset: number; + /** Whether angles are in degrees (will be converted to radians) */ + isDegrees?: boolean; + /** For more complex movements, a custom function that takes time and returns a value between 0 and 1 */ + customEasing?: (time: number) => number; +} + +/** + * Robot animation configuration interface + */ +export interface RobotAnimationConfig { + /** Array of joint configurations */ + joints: JointAnimationConfig[]; + /** Global speed multiplier */ + speedMultiplier?: number; +} + +export interface AnimationRequest { + robotName: string; + urdfContent: string; + description: string; // Natural language description of the desired animation +} + +export interface ContentItem { + id: string; + title: string; + imageUrl: string; + description?: string; + categories: string[]; + urdfPath: string; +} + +export interface Category { + id: string; + name: string; +} diff --git a/src/lib/urdfAnimationHelpers.ts b/src/lib/urdfAnimationHelpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..05175ec5c4b7637600a1c0710b631bfaeb700ca7 --- /dev/null +++ b/src/lib/urdfAnimationHelpers.ts @@ -0,0 +1,283 @@ +import { MathUtils } from "three"; +import { RobotAnimationConfig } from "@/lib/types"; + +// Define the interface for the Urdf viewer element +export interface UrdfViewerElement extends HTMLElement { + setJointValue: (joint: string, value: number) => void; +} + +/** + * Generalized animation function for any robot + * @param viewer The Urdf viewer element + * @param config Configuration for the robot's joint animations + * @returns A cleanup function to cancel the animation + */ +export function animateRobot( + viewer: UrdfViewerElement, + config: RobotAnimationConfig +): () => void { + let animationFrameId: number | null = null; + let isRunning = true; + const speedMultiplier = config.speedMultiplier || 1; + + const animate = () => { + if (!isRunning) return; + + const time = Date.now() / 300; // Base time unit + + try { + // Process each joint configuration + for (const joint of config.joints) { + // Calculate the animation ratio (0-1) based on the animation type + let ratio = 0; + const adjustedTime = + time * joint.speed * speedMultiplier + joint.offset; + + switch (joint.type) { + case "sine": + // Sine wave oscillation mapped to 0-1 + ratio = (Math.sin(adjustedTime) + 1) / 2; + break; + case "linear": + // Saw tooth pattern (0 to 1 repeated) + ratio = (adjustedTime % (2 * Math.PI)) / (2 * Math.PI); + break; + case "constant": + // Constant value (using max) + ratio = 1; + break; + default: + // Use custom easing if provided + if (joint.customEasing) { + ratio = joint.customEasing(adjustedTime); + } + } + + // Calculate the joint value based on min/max and the ratio + let value = MathUtils.lerp(joint.min, joint.max, ratio); + + // Convert from degrees to radians if specified + if (joint.isDegrees) { + value = (value * Math.PI) / 180; + } + + // Set the joint value, catching errors for non-existent joints + try { + viewer.setJointValue(joint.name, value); + } catch (e) { + // Silently ignore if the joint doesn't exist + } + } + } catch (err) { + console.error("Error in robot animation:", err); + } + + // Continue the animation loop + animationFrameId = requestAnimationFrame(animate); + }; + + // Start the animation + animationFrameId = requestAnimationFrame(animate); + + // Return cleanup function + return () => { + isRunning = false; + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + }; +} + +/** + * Animates a hexapod robot (like T12) with walking motion + * @param viewer The Urdf viewer element + * @returns A cleanup function to cancel the animation + */ +export function animateHexapodRobot(viewer: UrdfViewerElement): () => void { + let animationFrameId: number | null = null; + let isRunning = true; + + const animate = () => { + // Don't continue animation if we've been told to stop + if (!isRunning) return; + + // Animate the legs (for T12 robot) + const time = Date.now() / 3e2; + + try { + for (let i = 1; i <= 6; i++) { + const offset = (i * Math.PI) / 3; + const ratio = Math.max(0, Math.sin(time + offset)); + + // For a hexapod robot like T12 + if (typeof viewer.setJointValue === "function") { + // Hip joints + viewer.setJointValue( + `HP${i}`, + (MathUtils.lerp(30, 0, ratio) * Math.PI) / 180 + ); + // Knee joints + viewer.setJointValue( + `KP${i}`, + (MathUtils.lerp(90, 150, ratio) * Math.PI) / 180 + ); + // Ankle joints + viewer.setJointValue( + `AP${i}`, + (MathUtils.lerp(-30, -60, ratio) * Math.PI) / 180 + ); + + // Check if these joints exist before setting values + try { + // Tire/Contact joints + viewer.setJointValue(`TC${i}A`, MathUtils.lerp(0, 0.065, ratio)); + viewer.setJointValue(`TC${i}B`, MathUtils.lerp(0, 0.065, ratio)); + // Wheel rotation + viewer.setJointValue(`W${i}`, performance.now() * 0.001); + } catch (e) { + // Silently ignore if those joints don't exist + } + } + } + } catch (err) { + console.error("Error in animation:", err); + } + + // Continue the animation loop + animationFrameId = requestAnimationFrame(animate); + }; + + // Start the animation + animationFrameId = requestAnimationFrame(animate); + + // Return cleanup function + return () => { + // Mark animation as stopped but DO NOT reset joint positions + isRunning = false; + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + }; +} + +// Example: Walking animation for Cassie robot +export const cassieWalkingConfig: RobotAnimationConfig = { + speedMultiplier: 0.5, // Adjust overall speed + joints: [ + // Left leg + { + name: "hip_abduction_left", + type: "sine", + min: -0.1, // Small side-to-side movement + max: 0.1, + speed: 1, + offset: 0, + isDegrees: false, // Already in radians + }, + { + name: "hip_rotation_left", // Assuming this joint exists + type: "sine", + min: -0.2, + max: 0.2, + speed: 1, + offset: Math.PI / 2, // 90 degrees out of phase + isDegrees: false, + }, + { + name: "hip_flexion_left", // Assuming this joint exists + type: "sine", + min: -0.3, + max: 0.6, + speed: 1, + offset: 0, + isDegrees: false, + }, + { + name: "knee_joint_left", // Assuming this joint exists + type: "sine", + min: 0.2, + max: 1.4, + speed: 1, + offset: Math.PI / 2, // 90 degrees phase shifted from hip + isDegrees: false, + }, + { + name: "ankle_joint_left", // Assuming this joint exists + type: "sine", + min: -0.4, + max: 0.1, + speed: 1, + offset: Math.PI, // 180 degrees out of phase with hip + isDegrees: false, + }, + { + name: "toe_joint_left", // Assuming this joint exists + type: "sine", + min: -0.2, + max: 0.2, + speed: 1, + offset: Math.PI * 1.5, // 270 degrees phase + isDegrees: false, + }, + + // Right leg (with appropriate phase shift to alternate with left leg) + { + name: "hip_abduction_right", // Assuming this joint exists + type: "sine", + min: -0.1, + max: 0.1, + speed: 1, + offset: Math.PI, // 180 degrees out of phase with left side + isDegrees: false, + }, + { + name: "hip_rotation_right", // Assuming this joint exists + type: "sine", + min: -0.2, + max: 0.2, + speed: 1, + offset: Math.PI + Math.PI / 2, // 180 + 90 degrees phase + isDegrees: false, + }, + { + name: "hip_flexion_right", // Assuming this joint exists + type: "sine", + min: -0.3, + max: 0.6, + speed: 1, + offset: Math.PI, // 180 degrees out of phase with left hip + isDegrees: false, + }, + { + name: "knee_joint_right", // Assuming this joint exists + type: "sine", + min: 0.2, + max: 1.4, + speed: 1, + offset: Math.PI + Math.PI / 2, // 180 + 90 degrees phase + isDegrees: false, + }, + { + name: "ankle_joint_right", // Assuming this joint exists + type: "sine", + min: -0.4, + max: 0.1, + speed: 1, + offset: 0, // 180 + 180 degrees = 360 = 0 + isDegrees: false, + }, + { + name: "toe_joint_right", // Assuming this joint exists + type: "sine", + min: -0.2, + max: 0.2, + speed: 1, + offset: Math.PI / 2, // 180 + 270 = 450 degrees = 90 degrees + isDegrees: false, + }, + ], +}; diff --git a/src/lib/urdfViewerHelpers.ts b/src/lib/urdfViewerHelpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..db2a1c7c8854b65683821b6d1dd6f445be4dc1c0 --- /dev/null +++ b/src/lib/urdfViewerHelpers.ts @@ -0,0 +1,259 @@ +import { + LoadingManager, + Object3D, + PerspectiveCamera, + Vector3, + Color, + AmbientLight, + DirectionalLight, + Scene, +} from "three"; +import { toast } from "@/components/ui/sonner"; +import { loadMeshFile } from "./meshLoaders"; + +// Define the interface for the URDF viewer element +export interface URDFViewerElement extends HTMLElement { + setJointValue: (joint: string, value: number) => void; + loadMeshFunc?: ( + path: string, + manager: LoadingManager, + done: (result: Object3D | null, err?: Error) => void + ) => void; + + // Extended properties for camera fitting + camera: PerspectiveCamera; + controls: { + target: Vector3; + update: () => void; + }; + robot: Object3D; + redraw: () => void; + up: string; + scene: Scene; +} + +/** + * Creates and configures a URDF viewer element + */ +export function createUrdfViewer( + container: HTMLDivElement, + isDarkMode: boolean +): URDFViewerElement { + // Clear any existing content + container.innerHTML = ""; + + // Create the urdf-viewer element + const viewer = document.createElement("urdf-viewer") as URDFViewerElement; + viewer.classList.add("w-full", "h-full"); + + // Add the element to the container + container.appendChild(viewer); + + // Set initial viewer properties + viewer.setAttribute("up", "Z"); + setViewerColor(viewer, isDarkMode ? "#2c2b3a" : "#eff4ff"); + viewer.setAttribute("highlight-color", isDarkMode ? "#df6dd4" : "#b05ffe"); + viewer.setAttribute("auto-redraw", "true"); + // viewer.setAttribute("display-shadow", ""); // Enable shadows + + // Add ambient light to the scene + const ambientLight = new AmbientLight(0xd6d6d6, 1); // Increased intensity to 0.4 + viewer.scene.add(ambientLight); + + // Add directional light for better shadows and depth + const directionalLight = new DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(5, 30, 5); + directionalLight.castShadow = true; + viewer.scene.add(directionalLight); + + // Set initial camera position for more zoomed-in view + // Wait for the viewer to be fully initialized before adjusting camera + setTimeout(() => { + if (viewer.camera) { + // Move camera closer to the robot for a more zoomed-in initial view + viewer.camera.position.set(0.5, 0.3, 0.5); + viewer.camera.lookAt(0, 0.2, 0); // Look at center of robot + + // Update controls target if available + if (viewer.controls) { + viewer.controls.target.set(0, 0.2, 0); + viewer.controls.update(); + } + + // Trigger a redraw + if (viewer.redraw) { + viewer.redraw(); + } + } + }, 100); + + return viewer; +} + +/** + * Setup mesh loading function for URDF viewer + */ +export function setupMeshLoader( + viewer: URDFViewerElement, + urlModifierFunc: ((url: string) => string) | null +): void { + if ("loadMeshFunc" in viewer) { + viewer.loadMeshFunc = ( + path: string, + manager: LoadingManager, + done: (result: Object3D | null, err?: Error) => void + ) => { + // Apply URL modifier if available (for custom uploads) + const modifiedPath = urlModifierFunc ? urlModifierFunc(path) : path; + + // If loading fails, log the error but continue + try { + loadMeshFile(modifiedPath, manager, (result, err) => { + if (err) { + console.warn(`Error loading mesh ${modifiedPath}:`, err); + // Try to continue with other meshes + done(null); + } else { + done(result); + } + }); + } catch (err) { + console.error(`Exception loading mesh ${modifiedPath}:`, err); + done(null, err as Error); + } + }; + } +} + +/** + * Setup event handlers for joint highlighting + */ +export function setupJointHighlighting( + viewer: URDFViewerElement, + setHighlightedJoint: (joint: string | null) => void +): () => void { + const onJointMouseover = (e: Event) => { + const customEvent = e as CustomEvent; + setHighlightedJoint(customEvent.detail); + }; + + const onJointMouseout = () => { + setHighlightedJoint(null); + }; + + // Add event listeners + viewer.addEventListener("joint-mouseover", onJointMouseover); + viewer.addEventListener("joint-mouseout", onJointMouseout); + + // Return cleanup function + return () => { + viewer.removeEventListener("joint-mouseover", onJointMouseover); + viewer.removeEventListener("joint-mouseout", onJointMouseout); + }; +} + +/** + * Setup model loading and error handling + */ +export function setupModelLoading( + viewer: URDFViewerElement, + urdfPath: string, + packagePath: string, + setCustomUrdfPath: (path: string) => void, + alternativeRobotModels: string[] = [] // Add parameter for alternative models +): () => void { + // Add XML content type hint for blob URLs + const loadPath = + urdfPath.startsWith("blob:") && !urdfPath.includes("#.") + ? urdfPath + "#.urdf" // Add extension hint if it's a blob URL + : urdfPath; + + // Set the URDF path + viewer.setAttribute("urdf", loadPath); + viewer.setAttribute("package", packagePath); + + // Handle successful loading and set initial zoom + const onLoadSuccess = () => { + // Set more zoomed-in camera position after model loads + setTimeout(() => { + if (viewer.camera && viewer.robot) { + // Position camera closer for better initial view + viewer.camera.position.set(0.4, 0.25, 0.4); + viewer.camera.lookAt(0, 0.15, 0); + + if (viewer.controls) { + viewer.controls.target.set(0, 0.15, 0); + viewer.controls.update(); + } + + if (viewer.redraw) { + viewer.redraw(); + } + } + }, 50); + }; + + // Handle error loading + const onLoadError = () => { + // toast.error("Failed to load model", { + // description: "There was an error loading the URDF model.", + // duration: 3000, + // }); + + // Use the provided alternativeRobotModels instead of the global window object + if (alternativeRobotModels.length > 0) { + const nextModel = alternativeRobotModels[0]; + if (nextModel) { + setCustomUrdfPath(nextModel); + toast.info("Trying alternative model...", { + description: `First model failed to load. Trying ${ + nextModel.split("/").pop() || "alternative model" + }`, + duration: 2000, + }); + } + } + }; + + viewer.addEventListener("error", onLoadError); + viewer.addEventListener("urdf-processed", onLoadSuccess); + + // Return cleanup function + return () => { + viewer.removeEventListener("error", onLoadError); + viewer.removeEventListener("urdf-processed", onLoadSuccess); + }; +} + +/** + * Sets the background color of the URDF viewer + */ +export function setViewerColor(viewer: URDFViewerElement, color: string): void { + // Set the ambient color for the scene + // viewer.setAttribute("ambient-color", color); + + // Set the background color on the viewer's parent container + const container = viewer.parentElement; + if (container) { + container.style.backgroundColor = color; + } +} + +/** + * Updates the viewer's colors based on the current theme + */ +export function updateViewerTheme( + viewer: URDFViewerElement, + isDarkMode: boolean +): void { + // Update the ambient color + setViewerColor(viewer, isDarkMode ? "#2c2b3a" : "#eff4ff"); + viewer.setAttribute("highlight-color", isDarkMode ? "#df6dd4" : "#b05ffe"); + + // // Update the ambient light intensity based on theme + // viewer.scene.traverse((object) => { + // if (object instanceof AmbientLight) { + // object.intensity = isDarkMode ? 0.4 : 0.6; // Brighter in light mode + // } + // }); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd0c391ddd1088e9067844c48835bf4abcd61783 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..719464e3da4bc77d3adebed4b6c12d3327f5b89f --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +createRoot(document.getElementById("root")!).render(<App />); diff --git a/src/pages/Calibration.tsx b/src/pages/Calibration.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f7b21ef74f728e225fbc786c9d49a2d43a8f5ad --- /dev/null +++ b/src/pages/Calibration.tsx @@ -0,0 +1,739 @@ +import { useState, useEffect, useRef, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + ArrowLeft, + Settings, + Wrench, + Activity, + CheckCircle, + XCircle, + AlertCircle, + Loader2, + Play, + Square, + RefreshCw, + Trash2, + List, +} from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; + +interface CalibrationStatus { + calibration_active: boolean; + status: string; // "idle", "connecting", "calibrating", "completed", "error", "stopping" + device_type: string | null; + error: string | null; + message: string; + console_output: string; +} + +interface CalibrationRequest { + device_type: string; // "robot" or "teleop" + port: string; + config_file: string; +} + +interface CalibrationConfig { + name: string; + filename: string; + size: number; + modified: number; +} + +// ConfigsResponse interface removed since we're using text input + +const Calibration = () => { + const navigate = useNavigate(); + const { toast } = useToast(); + + // Ref for auto-scrolling console + const consoleRef = useRef<HTMLDivElement>(null); + + // Form state + const [deviceType, setDeviceType] = useState<string>("robot"); + const [port, setPort] = useState<string>(""); + const [configFile, setConfigFile] = useState<string>(""); + + // Config loading and management + const [isLoadingConfigs, setIsLoadingConfigs] = useState(false); + const [availableConfigs, setAvailableConfigs] = useState<CalibrationConfig[]>( + [] + ); + + // Calibration state + const [calibrationStatus, setCalibrationStatus] = useState<CalibrationStatus>( + { + calibration_active: false, + status: "idle", + device_type: null, + error: null, + message: "", + console_output: "", + } + ); + const [isPolling, setIsPolling] = useState(false); + + // Config loading removed since we're using text input now + + // Poll calibration status + const pollStatus = async () => { + try { + const response = await fetch("http://localhost:8000/calibration-status"); + if (response.ok) { + const status = await response.json(); + setCalibrationStatus(status); + + // Stop polling if calibration is completed or error + if ( + !status.calibration_active && + (status.status === "completed" || status.status === "error") + ) { + setIsPolling(false); + } + } + } catch (error) { + console.error("Error polling status:", error); + } + }; + + // Start calibration + const handleStartCalibration = async () => { + if (!deviceType || !port || !configFile) { + toast({ + title: "Missing Information", + description: "Please fill in all required fields", + variant: "destructive", + }); + return; + } + + const request: CalibrationRequest = { + device_type: deviceType, + port: port, + config_file: configFile, + }; + + try { + const response = await fetch("http://localhost:8000/start-calibration", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + const result = await response.json(); + + if (result.success) { + toast({ + title: "Calibration Started", + description: `Calibration started for ${deviceType}`, + }); + setIsPolling(true); + } else { + toast({ + title: "Calibration Failed", + description: result.message || "Failed to start calibration", + variant: "destructive", + }); + } + } catch (error) { + console.error("Error starting calibration:", error); + toast({ + title: "Error", + description: "Failed to start calibration", + variant: "destructive", + }); + } + }; + + // Stop calibration + const handleStopCalibration = async () => { + try { + const response = await fetch("http://localhost:8000/stop-calibration", { + method: "POST", + }); + + const result = await response.json(); + + if (result.success) { + toast({ + title: "Calibration Stopped", + description: "Calibration has been stopped", + }); + setIsPolling(false); + } else { + toast({ + title: "Error", + description: result.message || "Failed to stop calibration", + variant: "destructive", + }); + } + } catch (error) { + console.error("Error stopping calibration:", error); + toast({ + title: "Error", + description: "Failed to stop calibration", + variant: "destructive", + }); + } + }; + + // Reset form + const handleReset = () => { + setDeviceType("robot"); + setPort(""); + setConfigFile(""); + setAvailableConfigs([]); + setCalibrationStatus({ + calibration_active: false, + status: "idle", + device_type: null, + error: null, + message: "", + console_output: "", + }); + setIsPolling(false); + }; + + // Load available configs for the selected device type + const loadAvailableConfigs = async (deviceType: string) => { + if (!deviceType) return; + + setIsLoadingConfigs(true); + try { + const response = await fetch( + `http://localhost:8000/calibration-configs/${deviceType}` + ); + const data = await response.json(); + + if (data.success) { + setAvailableConfigs(data.configs || []); + } else { + toast({ + title: "Error Loading Configs", + description: data.message || "Could not load calibration configs", + variant: "destructive", + }); + } + } catch (error) { + toast({ + title: "Error Loading Configs", + description: "Could not connect to the backend server", + variant: "destructive", + }); + } finally { + setIsLoadingConfigs(false); + } + }; + + // Delete a config file + const handleDeleteConfig = async (configName: string) => { + if (!deviceType) return; + + try { + const response = await fetch( + `http://localhost:8000/calibration-configs/${deviceType}/${configName}`, + { method: "DELETE" } + ); + const data = await response.json(); + + if (data.success) { + toast({ + title: "Config Deleted", + description: data.message, + }); + // Reload the configs list + loadAvailableConfigs(deviceType); + } else { + toast({ + title: "Delete Failed", + description: data.message || "Could not delete the configuration", + variant: "destructive", + }); + } + } catch (error) { + toast({ + title: "Error", + description: "Could not delete the configuration", + variant: "destructive", + }); + } + }; + + // Send Enter to calibration process + const handleSendEnter = async () => { + if (!calibrationStatus.calibration_active) return; + + console.log("🔵 Enter button clicked - sending input..."); + + try { + const response = await fetch("http://localhost:8000/calibration-input", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ input: "\n" }), // Send actual newline character + }); + + const data = await response.json(); + console.log("🔵 Server response:", data); + + if (data.success) { + toast({ + title: "Enter Sent", + description: "Enter key sent to calibration process", + }); + } else { + toast({ + title: "Input Failed", + description: data.message || "Could not send Enter", + variant: "destructive", + }); + } + } catch (error) { + console.error("🔴 Error sending Enter:", error); + toast({ + title: "Error", + description: "Could not send Enter to calibration", + variant: "destructive", + }); + } + }; + + // Config loading removed - using text input instead + + // Set up polling + useEffect(() => { + let interval: NodeJS.Timeout; + + if (isPolling) { + // Use ultra-fast polling during active calibration for real-time updates + const pollInterval = + calibrationStatus.status === "calibrating" ? 25 : 100; + interval = setInterval(pollStatus, pollInterval); // 25ms during calibration, 100ms otherwise + pollStatus(); // Initial poll + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [isPolling, calibrationStatus.status]); + + // Load configs when device type changes + useEffect(() => { + if (deviceType) { + loadAvailableConfigs(deviceType); + } else { + setAvailableConfigs([]); + } + }, [deviceType]); + + // Auto-scroll console to bottom when output changes (with debounce) + useEffect(() => { + if (consoleRef.current && calibrationStatus.console_output) { + // Small delay to ensure DOM is updated before scrolling + const timeoutId = setTimeout(() => { + if (consoleRef.current) { + consoleRef.current.scrollTop = consoleRef.current.scrollHeight; + } + }, 10); + + return () => clearTimeout(timeoutId); + } + }, [calibrationStatus.console_output]); + + // Get status color and icon + const getStatusDisplay = () => { + switch (calibrationStatus.status) { + case "idle": + return { + color: "bg-gray-500", + icon: <Settings className="w-4 h-4" />, + text: "Idle", + }; + case "connecting": + return { + color: "bg-yellow-500", + icon: <Loader2 className="w-4 h-4 animate-spin" />, + text: "Connecting", + }; + case "calibrating": + return { + color: "bg-blue-500", + icon: <Activity className="w-4 h-4" />, + text: "Calibrating", + }; + case "completed": + return { + color: "bg-green-500", + icon: <CheckCircle className="w-4 h-4" />, + text: "Completed", + }; + case "error": + return { + color: "bg-red-500", + icon: <XCircle className="w-4 h-4" />, + text: "Error", + }; + case "stopping": + return { + color: "bg-orange-500", + icon: <Square className="w-4 h-4" />, + text: "Stopping", + }; + default: + return { + color: "bg-gray-500", + icon: <Settings className="w-4 h-4" />, + text: "Unknown", + }; + } + }; + + const statusDisplay = getStatusDisplay(); + + // Memoize console output to prevent unnecessary re-renders + const memoizedConsoleOutput = useMemo(() => { + return ( + calibrationStatus.console_output || "Waiting for calibration output..." + ); + }, [calibrationStatus.console_output]); + + return ( + <div className="min-h-screen bg-gray-900 text-white p-4"> + <div className="max-w-4xl mx-auto"> + {/* Header */} + <div className="flex items-center gap-4 mb-6"> + <Button + variant="outline" + size="sm" + onClick={() => navigate("/")} + className="border-gray-700 hover:bg-gray-800" + > + <ArrowLeft className="w-4 h-4 mr-2" /> + Back to Home + </Button> + <div className="flex items-center gap-3"> + <Wrench className="w-8 h-8 text-orange-500" /> + <h1 className="text-3xl font-bold">Device Calibration</h1> + </div> + </div> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + {/* Configuration Panel */} + <Card className="bg-gray-800 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Settings className="w-5 h-5" /> + Calibration Configuration + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + {/* Device Type Selection */} + <div className="space-y-2"> + <Label + htmlFor="deviceType" + className="text-sm font-medium text-gray-300" + > + Device Type * + </Label> + <Select value={deviceType} onValueChange={setDeviceType}> + <SelectTrigger className="bg-gray-700 border-gray-600 text-white"> + <SelectValue placeholder="Select device type" /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-700"> + <SelectItem + value="robot" + className="text-white hover:bg-gray-700" + > + Robot (Follower) + </SelectItem> + <SelectItem + value="teleop" + className="text-white hover:bg-gray-700" + > + Teleoperator (Leader) + </SelectItem> + </SelectContent> + </Select> + </div> + + {/* Port Configuration */} + <div className="space-y-2"> + <Label + htmlFor="port" + className="text-sm font-medium text-gray-300" + > + Port * + </Label> + <Input + id="port" + value={port} + onChange={(e) => setPort(e.target.value)} + placeholder="/dev/tty.usbmodem5A460816421" + className="bg-gray-700 border-gray-600 text-white" + /> + </div> + + {/* Config File Name */} + <div className="space-y-2"> + <Label + htmlFor="configFile" + className="text-sm font-medium text-gray-300" + > + Calibration Config * + </Label> + <Input + id="configFile" + value={configFile} + onChange={(e) => setConfigFile(e.target.value)} + placeholder="config_name (without .json extension)" + className="bg-gray-700 border-gray-600 text-white" + /> + </div> + + {/* Available Configurations List */} + {deviceType && ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <List className="w-4 h-4 text-gray-400" /> + <Label className="text-sm font-medium text-gray-300"> + Available Configurations + </Label> + {isLoadingConfigs && ( + <Loader2 className="w-4 h-4 animate-spin text-gray-400" /> + )} + </div> + + <div className="max-h-40 overflow-y-auto bg-gray-700 rounded-lg border border-gray-600"> + {availableConfigs.length === 0 ? ( + <div className="p-3 text-center text-gray-400 text-sm"> + {isLoadingConfigs + ? "Loading..." + : "No configurations found"} + </div> + ) : ( + <div className="space-y-1 p-2"> + {availableConfigs.map((config) => ( + <div + key={config.name} + className="flex items-center justify-between bg-gray-600 rounded px-3 py-2 hover:bg-gray-500 transition-colors" + > + <div className="flex-1 min-w-0"> + <button + onClick={() => setConfigFile(config.name)} + className="text-left w-full text-white hover:text-blue-300 font-medium truncate" + title={`Click to select: ${config.name}`} + > + {config.name} + </button> + <div className="text-xs text-gray-400"> + {new Date( + config.modified * 1000 + ).toLocaleDateString()} + {" • "} + {(config.size / 1024).toFixed(1)} KB + </div> + </div> + <button + onClick={(e) => { + e.stopPropagation(); + handleDeleteConfig(config.name); + }} + className="ml-3 p-1 text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded transition-colors" + title={`Delete ${config.name}`} + > + <Trash2 className="w-4 h-4" /> + </button> + </div> + ))} + </div> + )} + </div> + </div> + )} + + <Separator className="bg-gray-700" /> + + {/* Action Buttons */} + <div className="flex flex-col gap-3"> + {!calibrationStatus.calibration_active ? ( + <Button + onClick={handleStartCalibration} + className="w-full bg-orange-500 hover:bg-orange-600 text-white py-6 text-lg" + disabled={ + isLoadingConfigs || !deviceType || !port || !configFile + } + > + <Play className="w-5 h-5 mr-2" /> + Start Calibration + </Button> + ) : ( + <Button + onClick={handleStopCalibration} + variant="destructive" + className="w-full py-6 text-lg" + > + <Square className="w-5 h-5 mr-2" /> + Stop Calibration + </Button> + )} + + <Button + onClick={handleReset} + variant="outline" + className="w-full border-gray-600 hover:bg-gray-700 py-6 text-lg" + disabled={calibrationStatus.calibration_active} + > + <RefreshCw className="w-5 h-5 mr-2" /> + Reset + </Button> + </div> + </CardContent> + </Card> + + {/* Status Panel */} + <Card className="bg-gray-800 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Activity className="w-5 h-5" /> + Calibration Status + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* Current Status */} + <div className="flex items-center justify-between"> + <span className="text-gray-300">Status:</span> + <Badge className={`${statusDisplay.color} text-white`}> + {statusDisplay.icon} + <span className="ml-2">{statusDisplay.text}</span> + </Badge> + </div> + + {calibrationStatus.device_type && ( + <div className="flex items-center justify-between"> + <span className="text-gray-300">Device:</span> + <span className="text-white capitalize"> + {calibrationStatus.device_type} + </span> + </div> + )} + + {/* Calibration Console - Show during calibration */} + {calibrationStatus.calibration_active && ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Settings className="w-4 h-4 text-gray-400" /> + <span className="text-sm font-medium text-gray-300"> + Calibration Console + </span> + </div> + + {/* Console Output */} + <div className="bg-black rounded-lg p-4 font-mono text-sm"> + <div + ref={consoleRef} + className="text-green-400 h-80 overflow-y-auto whitespace-pre-wrap" + > + {memoizedConsoleOutput} + </div> + </div> + + {/* Enter Button */} + <div className="flex justify-center"> + <Button + onClick={handleSendEnter} + disabled={!calibrationStatus.calibration_active} + className="bg-blue-500 hover:bg-blue-600 px-8 py-2" + > + Press Enter + </Button> + </div> + + <div className="text-xs text-gray-400 text-center"> + Click the button above to send Enter to the calibration + process + </div> + </div> + )} + + {/* Status Messages */} + {calibrationStatus.status === "connecting" && ( + <Alert className="bg-yellow-900/50 border-yellow-700"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + Connecting to the device. Please ensure the device is + properly connected. + </AlertDescription> + </Alert> + )} + + {calibrationStatus.status === "calibrating" && ( + <Alert className="bg-blue-900/50 border-blue-700"> + <Activity className="h-4 w-4" /> + <AlertDescription> + Calibration in progress. Please follow the instructions on + the device and do not disconnect. + </AlertDescription> + </Alert> + )} + + {calibrationStatus.status === "completed" && ( + <Alert className="bg-green-900/50 border-green-700"> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + Calibration completed successfully! The device is now ready + for use. + </AlertDescription> + </Alert> + )} + + {calibrationStatus.status === "error" && + calibrationStatus.error && ( + <Alert className="bg-red-900/50 border-red-700"> + <XCircle className="h-4 w-4" /> + <AlertDescription> + <strong>Error:</strong> {calibrationStatus.error} + </AlertDescription> + </Alert> + )} + + {/* Instructions */} + <div className="bg-gray-700 p-4 rounded-lg"> + <h4 className="font-semibold mb-2"> + Calibration Instructions: + </h4> + <ol className="text-sm text-gray-300 space-y-1"> + <li>1. Select the device type you want to calibrate</li> + <li>2. Enter the correct port for your device</li> + <li>3. Choose the appropriate calibration configuration</li> + <li>4. Move the robot in a middle position</li> + <li> + 5. Click "Start Calibration" and follow device prompts + </li> + <li>6. Move each motor all the way on both sides</li> + </ol> + </div> + </CardContent> + </Card> + </div> + </div> + </div> + ); +}; + +export default Calibration; diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b37fa2e5cd7e13fffd9a8632e3f7a6c285ac2aa --- /dev/null +++ b/src/pages/Index.tsx @@ -0,0 +1,147 @@ + +import React, { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useToast } from '@/hooks/use-toast'; +import { generateSensorData, generateMotorData } from '@/lib/mockData'; +import VisualizerPanel from '@/components/control/VisualizerPanel'; +import MetricsPanel from '@/components/control/MetricsPanel'; +import CommandBar from '@/components/control/CommandBar'; + +const Index = () => { + const [command, setCommand] = useState(''); + const [activeTab, setActiveTab] = useState<'SENSORS' | 'MOTORS'>('SENSORS'); + const [isVoiceActive, setIsVoiceActive] = useState(true); + const [showCamera, setShowCamera] = useState(false); + const [hasPermissions, setHasPermissions] = useState(false); + const [micLevel, setMicLevel] = useState(0); + const [sensorData, setSensorData] = useState<any[]>([]); + const [motorData, setMotorData] = useState<any[]>([]); + const { toast } = useToast(); + const navigate = useNavigate(); + + const videoRef = useRef<HTMLVideoElement>(null); + const streamRef = useRef<MediaStream | null>(null); + + useEffect(() => { + let audioContext: AudioContext | null = null; + const getPermissions = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true + }); + + streamRef.current = stream; + if (videoRef.current) { + videoRef.current.srcObject = stream; + } + + setHasPermissions(true); + + const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; + if (AudioContextClass) { + audioContext = new AudioContextClass(); + const analyser = audioContext.createAnalyser(); + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + let animationFrameId: number; + const dataArray = new Uint8Array(analyser.frequencyBinCount); + const updateMicLevel = () => { + if (audioContext?.state === 'closed') return; + analyser.getByteFrequencyData(dataArray); + const average = dataArray.reduce((a, b) => a + b) / dataArray.length; + setMicLevel(average); + animationFrameId = requestAnimationFrame(updateMicLevel); + }; + updateMicLevel(); + + return () => { + cancelAnimationFrame(animationFrameId); + audioContext?.close(); + }; + } + } catch (error) { + console.error("Permission to access media devices was denied.", error); + } + }; + + let cleanup: (() => void) | undefined; + getPermissions().then(returnedCleanup => { + cleanup = returnedCleanup; + }); + + return () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + } + cleanup?.(); + }; + }, []); + + useEffect(() => { + const interval = setInterval(() => { + setSensorData(prev => [...prev, generateSensorData()].slice(-50)); + setMotorData(prev => [...prev, generateMotorData()].slice(-50)); + }, 100); + + return () => clearInterval(interval); + }, []); + + const handleSendCommand = () => { + if (command.trim()) { + toast({ + title: "Command Sent", + description: `Robot command: "${command}"`, + }); + setCommand(''); + } + }; + + const handleGoBack = () => { + navigate('/'); + }; + + const handleEndSession = () => { + if (streamRef.current) { + streamRef.current.getTracks().forEach(track => track.stop()); + } + toast({ + title: "Session Ended", + description: "Robot control session terminated safely.", + variant: "destructive", + }); + navigate('/'); + }; + + return ( + <div className="min-h-screen bg-black text-white flex flex-col"> + <div className="flex-1 flex flex-col lg:flex-row"> + <VisualizerPanel onGoBack={handleGoBack} /> + <MetricsPanel + activeTab={activeTab} + setActiveTab={setActiveTab} + sensorData={sensorData} + motorData={motorData} + hasPermissions={hasPermissions} + streamRef={streamRef} + isVoiceActive={isVoiceActive} + micLevel={micLevel} + /> + </div> + + <CommandBar + command={command} + setCommand={setCommand} + handleSendCommand={handleSendCommand} + isVoiceActive={isVoiceActive} + setIsVoiceActive={setIsVoiceActive} + showCamera={showCamera} + setShowCamera={setShowCamera} + handleEndSession={handleEndSession} + /> + </div> + ); +}; + +export default Index; diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b2fe3f55c45c26d7205753b8a39b95d85b02c785 --- /dev/null +++ b/src/pages/Landing.tsx @@ -0,0 +1,701 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; +import { Camera, Mic, Settings, Wrench, GraduationCap } from "lucide-react"; + +const Landing = () => { + const [robotModel, setRobotModel] = useState(""); + const [showPermissionModal, setShowPermissionModal] = useState(false); + const [showTeleoperationModal, setShowTeleoperationModal] = useState(false); + const [leaderPort, setLeaderPort] = useState("/dev/tty.usbmodem5A460816421"); + const [followerPort, setFollowerPort] = useState( + "/dev/tty.usbmodem5A460816621" + ); + const [leaderConfig, setLeaderConfig] = useState(""); + const [followerConfig, setFollowerConfig] = useState(""); + const [leaderConfigs, setLeaderConfigs] = useState<string[]>([]); + const [followerConfigs, setFollowerConfigs] = useState<string[]>([]); + const [isLoadingConfigs, setIsLoadingConfigs] = useState(false); + + // Recording state + const [showRecordingModal, setShowRecordingModal] = useState(false); + const [recordLeaderPort, setRecordLeaderPort] = useState( + "/dev/tty.usbmodem5A460816421" + ); + const [recordFollowerPort, setRecordFollowerPort] = useState( + "/dev/tty.usbmodem5A460816621" + ); + const [recordLeaderConfig, setRecordLeaderConfig] = useState(""); + const [recordFollowerConfig, setRecordFollowerConfig] = useState(""); + const [datasetRepoId, setDatasetRepoId] = useState(""); + const [singleTask, setSingleTask] = useState(""); + const [numEpisodes, setNumEpisodes] = useState(5); + + const navigate = useNavigate(); + const { toast } = useToast(); + + const loadConfigs = async () => { + setIsLoadingConfigs(true); + try { + const response = await fetch("http://localhost:8000/get-configs"); + const data = await response.json(); + setLeaderConfigs(data.leader_configs || []); + setFollowerConfigs(data.follower_configs || []); + } catch (error) { + toast({ + title: "Error Loading Configs", + description: "Could not load calibration configs from the backend.", + variant: "destructive", + }); + } finally { + setIsLoadingConfigs(false); + } + }; + + const handleBeginSession = () => { + if (robotModel) { + setShowPermissionModal(true); + } + }; + + const handleTeleoperationClick = () => { + if (robotModel) { + setShowTeleoperationModal(true); + loadConfigs(); + } + }; + + const handleRecordingClick = () => { + if (robotModel) { + setShowRecordingModal(true); + loadConfigs(); + } + }; + + const handleCalibrationClick = () => { + if (robotModel) { + navigate("/calibration"); + } + }; + + const handleTrainingClick = () => { + if (robotModel) { + navigate("/training"); + } + }; + + const handleStartTeleoperation = async () => { + if (!leaderConfig || !followerConfig) { + toast({ + title: "Missing Configuration", + description: + "Please select calibration configs for both leader and follower.", + variant: "destructive", + }); + return; + } + + try { + const response = await fetch("http://localhost:8000/move-arm", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + leader_port: leaderPort, + follower_port: followerPort, + leader_config: leaderConfig, + follower_config: followerConfig, + }), + }); + + const data = await response.json(); + + if (response.ok) { + toast({ + title: "Teleoperation Started", + description: + data.message || "Successfully started teleoperation session.", + }); + setShowTeleoperationModal(false); + navigate("/teleoperation"); + } else { + toast({ + title: "Error Starting Teleoperation", + description: data.message || "Failed to start teleoperation session.", + variant: "destructive", + }); + } + } catch (error) { + toast({ + title: "Connection Error", + description: "Could not connect to the backend server.", + variant: "destructive", + }); + } + }; + + const handleStartRecording = async () => { + if ( + !recordLeaderConfig || + !recordFollowerConfig || + !datasetRepoId || + !singleTask + ) { + toast({ + title: "Missing Configuration", + description: + "Please fill in all required fields: calibration configs, dataset ID, and task name.", + variant: "destructive", + }); + return; + } + + // Navigate to recording page with configuration + const recordingConfig = { + leader_port: recordLeaderPort, + follower_port: recordFollowerPort, + leader_config: recordLeaderConfig, + follower_config: recordFollowerConfig, + dataset_repo_id: datasetRepoId, + single_task: singleTask, + num_episodes: numEpisodes, + episode_time_s: 60, // Default 60 seconds - use manual controls to end episodes early + reset_time_s: 15, // Default 15 seconds - use manual controls for faster resets + fps: 30, + video: true, + push_to_hub: false, + resume: false, + }; + + setShowRecordingModal(false); + navigate("/recording", { state: { recordingConfig } }); + }; + + const handlePermissions = async (allow: boolean) => { + setShowPermissionModal(false); + if (allow) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + stream.getTracks().forEach((track) => track.stop()); + toast({ + title: "Permissions Granted", + description: + "Camera and microphone access enabled. Entering control session...", + }); + navigate("/control"); + } catch (error) { + toast({ + title: "Permission Denied", + description: + "Camera and microphone access is required for robot control.", + variant: "destructive", + }); + } + } else { + toast({ + title: "Permission Denied", + description: "You can proceed, but with limited functionality.", + variant: "destructive", + }); + navigate("/control"); + } + }; + + return ( + <div className="min-h-screen bg-black text-white flex flex-col items-center justify-center p-4"> + <div className="text-center space-y-4 max-w-lg w-full"> + <img + src="/lovable-uploads/5e648747-34b7-4d8f-93fd-4dbd00aeeefc.png" + alt="LiveLab Logo" + className="mx-auto h-20 w-20" + /> + <h1 className="text-5xl font-bold tracking-tight">LiveLab</h1> + <p className="text-xl text-gray-400"> + Control a robotic arm through telepresence. Your browser becomes both + sensor and controller. + </p> + </div> + + <div className="mt-12 p-8 bg-gray-900 rounded-lg shadow-xl w-full max-w-lg space-y-6 border border-gray-700"> + <h2 className="text-2xl font-semibold text-center text-white"> + Select Robot Model + </h2> + <RadioGroup + value={robotModel} + onValueChange={setRobotModel} + className="space-y-4" + > + <div> + <RadioGroupItem value="SO100" id="so100" className="sr-only" /> + <Label + htmlFor="so100" + className="flex items-center space-x-4 p-4 rounded-md bg-gray-800 border border-gray-700 cursor-pointer transition-all has-[:checked]:border-orange-500 has-[:checked]:bg-gray-800/50" + > + <span className="w-6 h-6 rounded-full border-2 border-gray-500 flex items-center justify-center group-has-[:checked]:border-orange-500"> + {robotModel === "SO100" && ( + <span className="w-3 h-3 rounded-full bg-orange-500" /> + )} + </span> + <span className="text-lg flex-1">SO100</span> + </Label> + </div> + <div> + <RadioGroupItem value="SO101" id="so101" className="sr-only" /> + <Label + htmlFor="so101" + className="flex items-center space-x-4 p-4 rounded-md bg-gray-800 border border-gray-700 cursor-pointer transition-all has-[:checked]:border-orange-500 has-[:checked]:bg-gray-800/50" + > + <span className="w-6 h-6 rounded-full border-2 border-gray-500 flex items-center justify-center group-has-[:checked]:border-orange-500"> + {robotModel === "SO101" && ( + <span className="w-3 h-3 rounded-full bg-orange-500" /> + )} + </span> + <span className="text-lg flex-1">SO101</span> + </Label> + </div> + </RadioGroup> + <div className="flex flex-col sm:flex-row gap-4"> + <Button + onClick={handleBeginSession} + disabled={!robotModel} + className="flex-1 bg-orange-500 hover:bg-orange-600 text-white text-lg py-6 disabled:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed" + > + Begin Session + </Button> + <Button + onClick={handleCalibrationClick} + disabled={!robotModel} + className="flex-1 bg-blue-500 hover:bg-blue-600 text-white text-lg py-6 disabled:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed" + > + <Wrench className="w-5 h-5 mr-2" /> + Calibration + </Button> + <Button + onClick={handleTeleoperationClick} + disabled={!robotModel} + className="flex-1 bg-yellow-500 hover:bg-yellow-600 text-white text-lg py-6 disabled:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed" + > + Teleoperation + </Button> + <Button + onClick={handleRecordingClick} + disabled={!robotModel} + className="flex-1 bg-red-500 hover:bg-red-600 text-white text-lg py-6 disabled:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed" + > + Recording + </Button> + <Button + onClick={handleTrainingClick} + disabled={!robotModel} + className="flex-1 bg-purple-500 hover:bg-purple-600 text-white text-lg py-6 disabled:bg-gray-700 disabled:text-gray-400 disabled:cursor-not-allowed" + > + <GraduationCap className="w-5 h-5 mr-2" /> + Training + </Button> + </div> + </div> + + {/* Permission Modal */} + <Dialog open={showPermissionModal} onOpenChange={setShowPermissionModal}> + <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[480px] p-8"> + <DialogHeader> + <div className="flex justify-center items-center gap-4 mb-4"> + <Camera className="w-8 h-8 text-orange-500" /> + <Mic className="w-8 h-8 text-orange-500" /> + </div> + <DialogTitle className="text-white text-center text-2xl font-bold"> + Enable Camera & Microphone + </DialogTitle> + </DialogHeader> + <div className="text-center space-y-6 py-4"> + <DialogDescription className="text-gray-400 text-base leading-relaxed"> + LiveLab requires access to your camera and microphone for a fully + immersive telepresence experience. This enables real-time video + feedback and voice command capabilities. + </DialogDescription> + <div className="flex flex-col sm:flex-row gap-4 justify-center pt-2"> + <Button + onClick={() => handlePermissions(true)} + className="w-full sm:w-auto bg-orange-500 hover:bg-orange-600 text-white px-10 py-6 text-lg transition-all shadow-md shadow-orange-500/30 hover:shadow-lg hover:shadow-orange-500/40" + > + Allow Access + </Button> + <Button + onClick={() => handlePermissions(false)} + variant="outline" + className="w-full sm:w-auto border-gray-500 hover:border-gray-200 px-10 py-6 text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800" + > + Continue without + </Button> + </div> + </div> + </DialogContent> + </Dialog> + + {/* Teleoperation Configuration Modal */} + <Dialog + open={showTeleoperationModal} + onOpenChange={setShowTeleoperationModal} + > + <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[600px] p-8"> + <DialogHeader> + <div className="flex justify-center items-center gap-4 mb-4"> + <Settings className="w-8 h-8 text-yellow-500" /> + </div> + <DialogTitle className="text-white text-center text-2xl font-bold"> + Configure Teleoperation + </DialogTitle> + </DialogHeader> + <div className="space-y-6 py-4"> + <DialogDescription className="text-gray-400 text-base leading-relaxed text-center"> + Configure the robot arm ports and calibration settings for + teleoperation. + </DialogDescription> + + <div className="grid grid-cols-1 gap-6"> + <div className="space-y-2"> + <Label + htmlFor="leaderPort" + className="text-sm font-medium text-gray-300" + > + Leader Port + </Label> + <Input + id="leaderPort" + value={leaderPort} + onChange={(e) => setLeaderPort(e.target.value)} + placeholder="/dev/tty.usbmodem5A460816421" + className="bg-gray-800 border-gray-700 text-white" + /> + </div> + + <div className="space-y-2"> + <Label + htmlFor="leaderConfig" + className="text-sm font-medium text-gray-300" + > + Leader Calibration Config + </Label> + <Select value={leaderConfig} onValueChange={setLeaderConfig}> + <SelectTrigger className="bg-gray-800 border-gray-700 text-white"> + <SelectValue + placeholder={ + isLoadingConfigs + ? "Loading configs..." + : "Select leader config" + } + /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-700"> + {leaderConfigs.map((config) => ( + <SelectItem + key={config} + value={config} + className="text-white hover:bg-gray-700" + > + {config} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <Label + htmlFor="followerPort" + className="text-sm font-medium text-gray-300" + > + Follower Port + </Label> + <Input + id="followerPort" + value={followerPort} + onChange={(e) => setFollowerPort(e.target.value)} + placeholder="/dev/tty.usbmodem5A460816621" + className="bg-gray-800 border-gray-700 text-white" + /> + </div> + + <div className="space-y-2"> + <Label + htmlFor="followerConfig" + className="text-sm font-medium text-gray-300" + > + Follower Calibration Config + </Label> + <Select + value={followerConfig} + onValueChange={setFollowerConfig} + > + <SelectTrigger className="bg-gray-800 border-gray-700 text-white"> + <SelectValue + placeholder={ + isLoadingConfigs + ? "Loading configs..." + : "Select follower config" + } + /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-700"> + {followerConfigs.map((config) => ( + <SelectItem + key={config} + value={config} + className="text-white hover:bg-gray-700" + > + {config} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + + <div className="flex flex-col sm:flex-row gap-4 justify-center pt-4"> + <Button + onClick={handleStartTeleoperation} + className="w-full sm:w-auto bg-yellow-500 hover:bg-yellow-600 text-white px-10 py-6 text-lg transition-all shadow-md shadow-yellow-500/30 hover:shadow-lg hover:shadow-yellow-500/40" + disabled={isLoadingConfigs} + > + Start Teleoperation + </Button> + <Button + onClick={() => setShowTeleoperationModal(false)} + variant="outline" + className="w-full sm:w-auto border-gray-500 hover:border-gray-200 px-10 py-6 text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800" + > + Cancel + </Button> + </div> + </div> + </DialogContent> + </Dialog> + + {/* Recording Configuration Modal */} + <Dialog open={showRecordingModal} onOpenChange={setShowRecordingModal}> + <DialogContent className="bg-gray-900 border-gray-800 text-white sm:max-w-[600px] p-8 max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <div className="flex justify-center items-center gap-4 mb-4"> + <div className="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center"> + <span className="text-white font-bold text-sm">REC</span> + </div> + </div> + <DialogTitle className="text-white text-center text-2xl font-bold"> + Configure Recording + </DialogTitle> + </DialogHeader> + <div className="space-y-6 py-4"> + <DialogDescription className="text-gray-400 text-base leading-relaxed text-center"> + Configure the robot arm settings and dataset parameters for + recording. + </DialogDescription> + + <div className="grid grid-cols-1 gap-6"> + {/* Robot Configuration */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2"> + Robot Configuration + </h3> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label + htmlFor="recordLeaderPort" + className="text-sm font-medium text-gray-300" + > + Leader Port + </Label> + <Input + id="recordLeaderPort" + value={recordLeaderPort} + onChange={(e) => setRecordLeaderPort(e.target.value)} + placeholder="/dev/tty.usbmodem5A460816421" + className="bg-gray-800 border-gray-700 text-white" + /> + </div> + <div className="space-y-2"> + <Label + htmlFor="recordLeaderConfig" + className="text-sm font-medium text-gray-300" + > + Leader Calibration Config + </Label> + <Select + value={recordLeaderConfig} + onValueChange={setRecordLeaderConfig} + > + <SelectTrigger className="bg-gray-800 border-gray-700 text-white"> + <SelectValue + placeholder={ + isLoadingConfigs + ? "Loading configs..." + : "Select leader config" + } + /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-700"> + {leaderConfigs.map((config) => ( + <SelectItem + key={config} + value={config} + className="text-white hover:bg-gray-700" + > + {config} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label + htmlFor="recordFollowerPort" + className="text-sm font-medium text-gray-300" + > + Follower Port + </Label> + <Input + id="recordFollowerPort" + value={recordFollowerPort} + onChange={(e) => setRecordFollowerPort(e.target.value)} + placeholder="/dev/tty.usbmodem5A460816621" + className="bg-gray-800 border-gray-700 text-white" + /> + </div> + <div className="space-y-2"> + <Label + htmlFor="recordFollowerConfig" + className="text-sm font-medium text-gray-300" + > + Follower Calibration Config + </Label> + <Select + value={recordFollowerConfig} + onValueChange={setRecordFollowerConfig} + > + <SelectTrigger className="bg-gray-800 border-gray-700 text-white"> + <SelectValue + placeholder={ + isLoadingConfigs + ? "Loading configs..." + : "Select follower config" + } + /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-700"> + {followerConfigs.map((config) => ( + <SelectItem + key={config} + value={config} + className="text-white hover:bg-gray-700" + > + {config} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + </div> + + {/* Dataset Configuration */} + <div className="space-y-4"> + <h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2"> + Dataset Configuration + </h3> + <div className="grid grid-cols-1 gap-4"> + <div className="space-y-2"> + <Label + htmlFor="datasetRepoId" + className="text-sm font-medium text-gray-300" + > + Dataset Repository ID * + </Label> + <Input + id="datasetRepoId" + value={datasetRepoId} + onChange={(e) => setDatasetRepoId(e.target.value)} + placeholder="username/dataset_name" + className="bg-gray-800 border-gray-700 text-white" + /> + </div> + <div className="space-y-2"> + <Label + htmlFor="singleTask" + className="text-sm font-medium text-gray-300" + > + Task Name * + </Label> + <Input + id="singleTask" + value={singleTask} + onChange={(e) => setSingleTask(e.target.value)} + placeholder="e.g., pick_and_place" + className="bg-gray-800 border-gray-700 text-white" + /> + </div> + <div className="space-y-2"> + <Label + htmlFor="numEpisodes" + className="text-sm font-medium text-gray-300" + > + Number of Episodes + </Label> + <Input + id="numEpisodes" + type="number" + min="1" + max="100" + value={numEpisodes} + onChange={(e) => setNumEpisodes(parseInt(e.target.value))} + className="bg-gray-800 border-gray-700 text-white" + /> + </div> + </div> + </div> + </div> + + <div className="flex flex-col sm:flex-row gap-4 justify-center pt-4"> + <Button + onClick={handleStartRecording} + className="w-full sm:w-auto bg-red-500 hover:bg-red-600 text-white px-10 py-6 text-lg transition-all shadow-md shadow-red-500/30 hover:shadow-lg hover:shadow-red-500/40" + disabled={isLoadingConfigs} + > + Start Recording + </Button> + <Button + onClick={() => setShowRecordingModal(false)} + variant="outline" + className="w-full sm:w-auto border-gray-500 hover:border-gray-200 px-10 py-6 text-lg text-zinc-500 bg-zinc-900 hover:bg-zinc-800" + > + Cancel + </Button> + </div> + </div> + </DialogContent> + </Dialog> + </div> + ); +}; + +export default Landing; diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cda36dad66f37ea2f094f433985694a220a70d63 --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,27 @@ +import { useLocation } from "react-router-dom"; +import { useEffect } from "react"; + +const NotFound = () => { + const location = useLocation(); + + useEffect(() => { + console.error( + "404 Error: User attempted to access non-existent route:", + location.pathname + ); + }, [location.pathname]); + + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-100"> + <div className="text-center"> + <h1 className="text-4xl font-bold mb-4">404</h1> + <p className="text-xl text-gray-600 mb-4">Oops! Page not found</p> + <a href="/" className="text-blue-500 hover:text-blue-700 underline"> + Return to Home + </a> + </div> + </div> + ); +}; + +export default NotFound; diff --git a/src/pages/Recording.tsx b/src/pages/Recording.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4fd2b21c029668aae2ce06d234c43c261cbe2a52 --- /dev/null +++ b/src/pages/Recording.tsx @@ -0,0 +1,561 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { + ArrowLeft, + Square, + SkipForward, + RotateCcw, + Play, + GraduationCap, +} from "lucide-react"; + +interface RecordingConfig { + leader_port: string; + follower_port: string; + leader_config: string; + follower_config: string; + dataset_repo_id: string; + single_task: string; + num_episodes: number; + episode_time_s: number; + reset_time_s: number; + fps: number; + video: boolean; + push_to_hub: boolean; + resume: boolean; +} + +interface BackendStatus { + recording_active: boolean; + current_phase: string; + current_episode?: number; + total_episodes?: number; + phase_elapsed_seconds?: number; + phase_time_limit_s?: number; + session_elapsed_seconds?: number; + available_controls: { + stop_recording: boolean; + exit_early: boolean; + rerecord_episode: boolean; + }; +} + +const Recording = () => { + const location = useLocation(); + const navigate = useNavigate(); + const { toast } = useToast(); + + // Get recording config from navigation state + const recordingConfig = location.state?.recordingConfig as RecordingConfig; + + // Backend status state - this is the single source of truth + const [backendStatus, setBackendStatus] = useState<BackendStatus | null>( + null + ); + const [recordingSessionStarted, setRecordingSessionStarted] = useState(false); + + // Redirect if no config provided + useEffect(() => { + if (!recordingConfig) { + toast({ + title: "No Configuration", + description: "Please start recording from the main page.", + variant: "destructive", + }); + navigate("/"); + } + }, [recordingConfig, navigate, toast]); + + // Start recording session when component loads + useEffect(() => { + if (recordingConfig && !recordingSessionStarted) { + startRecordingSession(); + } + }, [recordingConfig, recordingSessionStarted]); + + // Poll backend status continuously to stay in sync + useEffect(() => { + let statusInterval: NodeJS.Timeout; + + if (recordingSessionStarted) { + const pollStatus = async () => { + try { + const response = await fetch( + "http://localhost:8000/recording-status" + ); + if (response.ok) { + const status = await response.json(); + setBackendStatus(status); + + // If backend recording stopped, session is complete + if (!status.recording_active && recordingSessionStarted) { + toast({ + title: "Recording Complete!", + description: `All episodes have been recorded successfully.`, + }); + } + } + } catch (error) { + console.error("Error polling recording status:", error); + } + }; + + // Poll immediately and then every second for real-time updates + pollStatus(); + statusInterval = setInterval(pollStatus, 1000); + } + + return () => { + if (statusInterval) clearInterval(statusInterval); + }; + }, [recordingSessionStarted, toast]); + + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, "0")}:${secs + .toString() + .padStart(2, "0")}`; + }; + + const startRecordingSession = async () => { + try { + const response = await fetch("http://localhost:8000/start-recording", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(recordingConfig), + }); + + const data = await response.json(); + + if (response.ok) { + setRecordingSessionStarted(true); + toast({ + title: "Recording Started", + description: `Started recording ${recordingConfig.num_episodes} episodes`, + }); + } else { + toast({ + title: "Error Starting Recording", + description: data.message || "Failed to start recording session.", + variant: "destructive", + }); + navigate("/"); + } + } catch (error) { + toast({ + title: "Connection Error", + description: "Could not connect to the backend server.", + variant: "destructive", + }); + navigate("/"); + } + }; + + // Equivalent to pressing RIGHT ARROW key in original record.py + const handleExitEarly = async () => { + if (!backendStatus?.available_controls.exit_early) return; + + try { + const response = await fetch( + "http://localhost:8000/recording-exit-early", + { + method: "POST", + } + ); + const data = await response.json(); + + if (response.ok) { + const currentPhase = backendStatus.current_phase; + if (currentPhase === "recording") { + toast({ + title: "Episode Recording Ended", + description: `Episode ${backendStatus.current_episode} recording completed. Moving to reset phase.`, + }); + } else if (currentPhase === "resetting") { + toast({ + title: "Reset Complete", + description: `Moving to next episode...`, + }); + } + } else { + toast({ + title: "Error", + description: data.message, + variant: "destructive", + }); + } + } catch (error) { + toast({ + title: "Connection Error", + description: "Could not connect to the backend server.", + variant: "destructive", + }); + } + }; + + // Equivalent to pressing LEFT ARROW key in original record.py + const handleRerecordEpisode = async () => { + if (!backendStatus?.available_controls.rerecord_episode) return; + + try { + const response = await fetch( + "http://localhost:8000/recording-rerecord-episode", + { + method: "POST", + } + ); + const data = await response.json(); + + if (response.ok) { + toast({ + title: "Re-recording Episode", + description: `Episode ${backendStatus.current_episode} will be re-recorded.`, + }); + } else { + toast({ + title: "Error", + description: data.message, + variant: "destructive", + }); + } + } catch (error) { + toast({ + title: "Connection Error", + description: "Could not connect to the backend server.", + variant: "destructive", + }); + } + }; + + // Equivalent to pressing ESC key in original record.py + const handleStopRecording = async () => { + try { + const response = await fetch("http://localhost:8000/stop-recording", { + method: "POST", + }); + + toast({ + title: "Recording Stopped", + description: "Recording session has been stopped.", + }); + navigate("/"); + } catch (error) { + toast({ + title: "Error", + description: "Failed to stop recording.", + variant: "destructive", + }); + } + }; + + if (!recordingConfig) { + return ( + <div className="min-h-screen bg-black text-white flex items-center justify-center"> + <div className="text-center"> + <p className="text-lg">No recording configuration found.</p> + <Button onClick={() => navigate("/")} className="mt-4"> + Return to Home + </Button> + </div> + </div> + ); + } + + // Show loading state while waiting for backend status + if (!backendStatus) { + return ( + <div className="min-h-screen bg-black text-white flex items-center justify-center"> + <div className="text-center"> + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-red-500 mx-auto mb-4"></div> + <p className="text-lg">Connecting to recording session...</p> + </div> + </div> + ); + } + + const currentPhase = backendStatus.current_phase; + const currentEpisode = backendStatus.current_episode || 1; + const totalEpisodes = + backendStatus.total_episodes || recordingConfig.num_episodes; + const phaseElapsedTime = backendStatus.phase_elapsed_seconds || 0; + const phaseTimeLimit = + backendStatus.phase_time_limit_s || + (currentPhase === "recording" + ? recordingConfig.episode_time_s + : recordingConfig.reset_time_s); + const sessionElapsedTime = backendStatus.session_elapsed_seconds || 0; + + const getPhaseTitle = () => { + if (currentPhase === "recording") return "Episode Recording Time"; + if (currentPhase === "resetting") return "Environment Reset Time"; + return "Phase Time"; + }; + + const getStatusText = () => { + if (currentPhase === "recording") + return `RECORDING EPISODE ${currentEpisode}`; + if (currentPhase === "resetting") return "RESET THE ENVIRONMENT"; + if (currentPhase === "preparing") return "PREPARING SESSION"; + return "SESSION COMPLETE"; + }; + + const getStatusColor = () => { + if (currentPhase === "recording") return "text-red-400"; + if (currentPhase === "resetting") return "text-orange-400"; + if (currentPhase === "preparing") return "text-yellow-400"; + return "text-gray-400"; + }; + + const getDotColor = () => { + if (currentPhase === "recording") return "bg-red-500 animate-pulse"; + if (currentPhase === "resetting") return "bg-orange-500 animate-pulse"; + if (currentPhase === "preparing") return "bg-yellow-500"; + return "bg-gray-500"; + }; + + return ( + <div className="min-h-screen bg-black text-white p-8"> + <div className="max-w-6xl mx-auto"> + {/* Header */} + <div className="flex items-center justify-between mb-8"> + <Button + onClick={() => navigate("/")} + variant="outline" + className="border-gray-500 hover:border-gray-200 text-gray-300 hover:text-white" + > + <ArrowLeft className="w-4 h-4 mr-2" /> + Back to Home + </Button> + + <div className="flex items-center gap-3"> + <div className={`w-3 h-3 rounded-full ${getDotColor()}`}></div> + <h1 className="text-3xl font-bold">Recording Session</h1> + </div> + </div> + + {/* Main Recording Dashboard */} + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> + {/* Phase Timer */} + <div className="bg-gray-900 rounded-lg p-6 border border-gray-700 text-center"> + <h2 className="text-sm text-gray-400 mb-2">{getPhaseTitle()}</h2> + <div + className={`text-4xl font-mono font-bold mb-2 ${ + currentPhase === "recording" + ? "text-green-400" + : "text-orange-400" + }`} + > + {formatTime(phaseElapsedTime)} + </div> + <div className="text-sm text-gray-400"> + / {formatTime(phaseTimeLimit)} + </div> + <div className="w-full bg-gray-700 rounded-full h-2 mt-3"> + <div + className={`h-2 rounded-full transition-all duration-1000 ${ + currentPhase === "recording" + ? "bg-green-500" + : "bg-orange-500" + }`} + style={{ + width: `${Math.min( + (phaseElapsedTime / phaseTimeLimit) * 100, + 100 + )}%`, + }} + ></div> + </div> + </div> + + {/* Episode Progress */} + <div className="bg-gray-900 rounded-lg p-6 border border-gray-700 text-center"> + <h2 className="text-sm text-gray-400 mb-2">Episode Progress</h2> + <div className="text-4xl font-bold text-blue-400 mb-2"> + {currentEpisode} of {totalEpisodes} + </div> + <div className="text-sm text-gray-400"> + {recordingConfig.single_task} + </div> + <div className="w-full bg-gray-700 rounded-full h-2 mt-3"> + <div + className="bg-blue-500 h-2 rounded-full transition-all duration-500" + style={{ width: `${(currentEpisode / totalEpisodes) * 100}%` }} + ></div> + </div> + </div> + + {/* Session Timer */} + <div className="bg-gray-900 rounded-lg p-6 border border-gray-700 text-center"> + <h2 className="text-sm text-gray-400 mb-2">Total Session Time</h2> + <div className="text-4xl font-mono font-bold text-yellow-400 mb-2"> + {formatTime(sessionElapsedTime)} + </div> + <div className="text-sm text-gray-400"> + Dataset: {recordingConfig.dataset_repo_id} + </div> + </div> + </div> + + {/* Status and Controls */} + <div className="bg-gray-900 rounded-lg p-6 border border-gray-700"> + <div className="flex items-center justify-between mb-6"> + <div> + <h2 className="text-xl font-semibold text-white mb-2"> + Recording Status + </h2> + <div className="flex items-center gap-3"> + <div className={`w-2 h-2 rounded-full ${getDotColor()}`}></div> + <span className={`font-semibold ${getStatusColor()}`}> + {getStatusText()} + </span> + </div> + </div> + </div> + + {/* Recording Phase Controls */} + {currentPhase === "recording" && ( + <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> + <Button + onClick={handleExitEarly} + disabled={!backendStatus.available_controls.exit_early} + className="bg-green-500 hover:bg-green-600 text-white font-semibold py-4 text-lg disabled:opacity-50" + > + <SkipForward className="w-5 h-5 mr-2" /> + End Episode + </Button> + + <Button + onClick={handleRerecordEpisode} + disabled={!backendStatus.available_controls.rerecord_episode} + className="bg-orange-500 hover:bg-orange-600 text-white font-semibold py-4 text-lg disabled:opacity-50" + > + <RotateCcw className="w-5 h-5 mr-2" /> + Re-record Episode + </Button> + + <Button + onClick={handleStopRecording} + disabled={!backendStatus.available_controls.stop_recording} + className="bg-red-500 hover:bg-red-600 text-white font-semibold py-4 text-lg disabled:opacity-50" + > + <Square className="w-5 h-5 mr-2" /> + Stop Recording + </Button> + </div> + )} + + {/* Reset Phase Controls */} + {currentPhase === "resetting" && ( + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + <Button + onClick={handleExitEarly} + disabled={!backendStatus.available_controls.exit_early} + className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-6 text-xl disabled:opacity-50" + > + <Play className="w-6 h-6 mr-2" /> + Continue to Next Phase + </Button> + + <Button + onClick={handleStopRecording} + disabled={!backendStatus.available_controls.stop_recording} + className="bg-red-500 hover:bg-red-600 text-white font-semibold py-6 text-xl disabled:opacity-50" + > + <Square className="w-5 h-5 mr-2" /> + Stop Recording + </Button> + </div> + )} + + {currentPhase === "completed" && ( + <div className="text-center"> + <p className="text-lg text-green-400 mb-6"> + ✅ Recording session completed successfully! + </p> + <p className="text-gray-400 mb-6"> + Dataset:{" "} + <span className="text-white font-semibold"> + {recordingConfig.dataset_repo_id} + </span> + </p> + <div className="flex flex-col sm:flex-row gap-4 justify-center"> + <Button + onClick={() => navigate("/training")} + className="bg-purple-500 hover:bg-purple-600 text-white font-semibold py-3 px-6 text-lg" + > + <GraduationCap className="w-5 h-5 mr-2" /> + Start Training + </Button> + <Button + onClick={() => navigate("/")} + variant="outline" + className="bg-transparent border-gray-600 text-gray-300 hover:bg-gray-800 hover:text-white py-3 px-6 text-lg" + > + Return to Home + </Button> + </div> + </div> + )} + + {/* Instructions */} + <div className="mt-6 p-4 bg-gray-800 rounded-lg"> + <h3 className="font-semibold mb-2"> + {currentPhase === "recording" + ? "Episode Recording Instructions:" + : currentPhase === "resetting" + ? "Environment Reset Instructions:" + : "Session Instructions:"} + </h3> + {currentPhase === "recording" && ( + <ul className="text-sm text-gray-400 space-y-1"> + <li> + • <strong>End Episode:</strong> Complete current episode and + enter reset phase (Right Arrow) + </li> + <li> + • <strong>Re-record Episode:</strong> Restart current episode + after reset phase (Left Arrow) + </li> + <li> + • <strong>Auto-end:</strong> Episode ends automatically after{" "} + {formatTime(phaseTimeLimit)} + </li> + <li> + • <strong>Stop Recording:</strong> End entire session (ESC + key) + </li> + </ul> + )} + {currentPhase === "resetting" && ( + <ul className="text-sm text-gray-400 space-y-1"> + <li> + • <strong>Continue to Next Phase:</strong> Skip reset phase + and continue (Right Arrow) + </li> + <li> + • <strong>Auto-continue:</strong> Automatically continues + after {formatTime(phaseTimeLimit)} + </li> + <li> + • <strong>Reset Phase:</strong> Use this time to prepare your + environment for the next episode + </li> + <li> + • <strong>Stop Recording:</strong> End entire session (ESC + key) + </li> + </ul> + )} + </div> + </div> + </div> + </div> + ); +}; + +export default Recording; diff --git a/src/pages/Teleoperation.tsx b/src/pages/Teleoperation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7548bc5aa3a434fb0793856aeedeaab8c7146057 --- /dev/null +++ b/src/pages/Teleoperation.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import VisualizerPanel from "@/components/control/VisualizerPanel"; +import { useToast } from "@/hooks/use-toast"; + +const TeleoperationPage = () => { + const navigate = useNavigate(); + const { toast } = useToast(); + + const handleGoBack = async () => { + try { + // Stop the teleoperation process before navigating back + console.log("🛑 Stopping teleoperation..."); + const response = await fetch("http://localhost:8000/stop-teleoperation", { + method: "POST", + }); + + if (response.ok) { + const result = await response.json(); + console.log("✅ Teleoperation stopped:", result.message); + toast({ + title: "Teleoperation Stopped", + description: + result.message || + "Robot teleoperation has been stopped successfully.", + }); + } else { + const errorText = await response.text(); + console.warn( + "⚠️ Failed to stop teleoperation:", + response.status, + errorText + ); + toast({ + title: "Warning", + description: `Failed to stop teleoperation properly. Status: ${response.status}`, + variant: "destructive", + }); + } + } catch (error) { + console.error("❌ Error stopping teleoperation:", error); + toast({ + title: "Error", + description: "Failed to communicate with the robot server.", + variant: "destructive", + }); + } finally { + // Navigate back regardless of the result + navigate("/"); + } + }; + + return ( + <div className="min-h-screen bg-black flex items-center justify-center p-2 sm:p-4"> + <div className="w-full h-[95vh] flex"> + <VisualizerPanel onGoBack={handleGoBack} className="lg:w-full" /> + </div> + </div> + ); +}; + +export default TeleoperationPage; diff --git a/src/pages/Training.tsx b/src/pages/Training.tsx new file mode 100644 index 0000000000000000000000000000000000000000..279d24bc0dc2500dd2dffe106bc003308fb27a74 --- /dev/null +++ b/src/pages/Training.tsx @@ -0,0 +1,1245 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useToast } from "@/components/ui/use-toast"; +import { + Play, + Square, + ArrowLeft, + Settings, + Activity, + FileText, + Cpu, + Database, + TrendingUp, + Clock, + AlertCircle, + CheckCircle, + Loader2, +} from "lucide-react"; + +interface TrainingConfig { + // Dataset configuration - exact matches from CLI + dataset_repo_id: string; // --dataset.repo_id + dataset_revision?: string; // --dataset.revision + dataset_root?: string; // --dataset.root + dataset_episodes?: number[]; // --dataset.episodes + + // Policy configuration - only type is configurable at top level + policy_type: string; // --policy.type (act, diffusion, pi0, smolvla, tdmpc, vqbet, pi0fast, sac, reward_classifier) + + // Core training parameters - exact matches from CLI + steps: number; // --steps + batch_size: number; // --batch_size + seed?: number; // --seed + num_workers: number; // --num_workers + + // Logging and checkpointing - exact matches from CLI + log_freq: number; // --log_freq + save_freq: number; // --save_freq + eval_freq: number; // --eval_freq + save_checkpoint: boolean; // --save_checkpoint + + // Output configuration - exact matches from CLI + output_dir: string; // --output_dir + resume: boolean; // --resume + job_name?: string; // --job_name + + // Weights & Biases - exact matches from CLI + wandb_enable: boolean; // --wandb.enable + wandb_project?: string; // --wandb.project + wandb_entity?: string; // --wandb.entity + wandb_notes?: string; // --wandb.notes + wandb_run_id?: string; // --wandb.run_id + wandb_mode?: string; // --wandb.mode (online, offline, disabled) + wandb_disable_artifact: boolean; // --wandb.disable_artifact + + // Environment and evaluation - exact matches from CLI + env_type?: string; // --env.type (aloha, pusht, xarm, gym_manipulator, hil) + env_task?: string; // --env.task + eval_n_episodes: number; // --eval.n_episodes + eval_batch_size: number; // --eval.batch_size + eval_use_async_envs: boolean; // --eval.use_async_envs + + // Policy-specific parameters that are commonly used + policy_device?: string; // --policy.device + policy_use_amp: boolean; // --policy.use_amp + + // Optimizer parameters - exact matches from CLI + optimizer_type?: string; // --optimizer.type (adam, adamw, sgd, multi_adam) + optimizer_lr?: number; // --optimizer.lr (will use policy default if not set) + optimizer_weight_decay?: number; // --optimizer.weight_decay + optimizer_grad_clip_norm?: number; // --optimizer.grad_clip_norm + + // Advanced configuration + use_policy_training_preset: boolean; // --use_policy_training_preset + config_path?: string; // --config_path +} + +interface TrainingStatus { + training_active: boolean; + current_step: number; + total_steps: number; + current_loss?: number; + current_lr?: number; + grad_norm?: number; + epoch_time?: number; + eta_seconds?: number; + available_controls: { + stop_training: boolean; + pause_training: boolean; + resume_training: boolean; + }; +} + +interface LogEntry { + timestamp: number; + message: string; +} + +const Training = () => { + const navigate = useNavigate(); + const { toast } = useToast(); + const logContainerRef = useRef<HTMLDivElement>(null); + + const [trainingConfig, setTrainingConfig] = useState<TrainingConfig>({ + dataset_repo_id: "", + policy_type: "act", + steps: 10000, + batch_size: 8, + seed: 1000, + num_workers: 4, + log_freq: 250, + save_freq: 1000, + eval_freq: 0, + save_checkpoint: true, + output_dir: "outputs/train", + resume: false, + wandb_enable: false, + wandb_mode: "online", + wandb_disable_artifact: false, + eval_n_episodes: 10, + eval_batch_size: 50, + eval_use_async_envs: false, + policy_device: "cuda", + policy_use_amp: false, + optimizer_type: "adam", + use_policy_training_preset: true, + }); + + const [trainingStatus, setTrainingStatus] = useState<TrainingStatus>({ + training_active: false, + current_step: 0, + total_steps: 0, + available_controls: { + stop_training: false, + pause_training: false, + resume_training: false, + }, + }); + + const [logs, setLogs] = useState<LogEntry[]>([]); + const [isStartingTraining, setIsStartingTraining] = useState(false); + const [activeTab, setActiveTab] = useState<"config" | "monitoring">("config"); + + // Poll for training status and logs + useEffect(() => { + const pollInterval = setInterval(async () => { + if (trainingStatus.training_active) { + try { + // Get status + const statusResponse = await fetch("/training-status"); + if (statusResponse.ok) { + const status = await statusResponse.json(); + setTrainingStatus(status); + } + + // Get logs + const logsResponse = await fetch("/training-logs"); + if (logsResponse.ok) { + const logsData = await logsResponse.json(); + if (logsData.logs && logsData.logs.length > 0) { + setLogs((prevLogs) => [...prevLogs, ...logsData.logs]); + } + } + } catch (error) { + console.error("Error polling training status:", error); + } + } + }, 1000); + + return () => clearInterval(pollInterval); + }, [trainingStatus.training_active]); + + // Auto-scroll logs + useEffect(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [logs]); + + const handleStartTraining = async () => { + if (!trainingConfig.dataset_repo_id.trim()) { + toast({ + title: "Error", + description: "Dataset repository ID is required", + variant: "destructive", + }); + return; + } + + setIsStartingTraining(true); + try { + const response = await fetch("/start-training", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(trainingConfig), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success) { + toast({ + title: "Training Started", + description: "Training session has been started successfully", + }); + setActiveTab("monitoring"); + setLogs([]); + } else { + toast({ + title: "Error", + description: result.message || "Failed to start training", + variant: "destructive", + }); + } + } else { + toast({ + title: "Error", + description: "Failed to start training", + variant: "destructive", + }); + } + } catch (error) { + console.error("Error starting training:", error); + toast({ + title: "Error", + description: "Failed to start training", + variant: "destructive", + }); + } finally { + setIsStartingTraining(false); + } + }; + + const handleStopTraining = async () => { + try { + const response = await fetch("/stop-training", { + method: "POST", + }); + + if (response.ok) { + const result = await response.json(); + if (result.success) { + toast({ + title: "Training Stopped", + description: "Training session has been stopped", + }); + } else { + toast({ + title: "Error", + description: result.message || "Failed to stop training", + variant: "destructive", + }); + } + } + } catch (error) { + console.error("Error stopping training:", error); + toast({ + title: "Error", + description: "Failed to stop training", + variant: "destructive", + }); + } + }; + + const updateConfig = <T extends keyof TrainingConfig>( + key: T, + value: TrainingConfig[T] + ) => { + setTrainingConfig((prev) => ({ ...prev, [key]: value })); + }; + + const formatTime = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + return `${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + }; + + const getProgressPercentage = () => { + if (trainingStatus.total_steps === 0) return 0; + return (trainingStatus.current_step / trainingStatus.total_steps) * 100; + }; + + const getStatusColor = () => { + if (trainingStatus.training_active) return "text-green-400"; + return "text-gray-400"; + }; + + const getStatusText = () => { + if (trainingStatus.training_active) return "Training Active"; + return "Ready to Train"; + }; + + return ( + <div className="min-h-screen bg-gray-950 text-white p-4"> + <div className="max-w-7xl mx-auto"> + {/* Header */} + <div className="flex items-center justify-between mb-8"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="sm" + onClick={() => navigate("/")} + className="text-gray-400 hover:text-white" + > + <ArrowLeft className="w-4 h-4 mr-2" /> + Back to Home + </Button> + <h1 className="text-4xl font-bold text-white"> + Training Dashboard + </h1> + </div> + + <div className="flex items-center gap-3"> + <div + className={`w-2 h-2 rounded-full ${ + trainingStatus.training_active ? "bg-green-400" : "bg-gray-400" + }`} + ></div> + <span className={`font-semibold ${getStatusColor()}`}> + {getStatusText()} + </span> + </div> + </div> + + {/* Tab Navigation */} + <div className="flex gap-2 mb-6"> + <Button + variant={activeTab === "config" ? "default" : "ghost"} + onClick={() => setActiveTab("config")} + className="flex items-center gap-2" + > + <Settings className="w-4 h-4" /> + Configuration + </Button> + <Button + variant={activeTab === "monitoring" ? "default" : "ghost"} + onClick={() => setActiveTab("monitoring")} + className="flex items-center gap-2" + > + <Activity className="w-4 h-4" /> + Monitoring + </Button> + </div> + + {/* Configuration Tab */} + {activeTab === "config" && ( + <div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> + {/* Dataset Configuration */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <Database className="w-5 h-5" /> + Dataset Configuration + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="dataset_repo_id" className="text-gray-300"> + Dataset Repository ID * + </Label> + <Input + id="dataset_repo_id" + value={trainingConfig.dataset_repo_id} + onChange={(e) => + updateConfig("dataset_repo_id", e.target.value) + } + placeholder="e.g., your-username/your-dataset" + className="bg-gray-800 border-gray-600 text-white" + /> + <p className="text-xs text-gray-500 mt-1"> + HuggingFace Hub dataset repository ID + </p> + </div> + + <div> + <Label htmlFor="dataset_revision" className="text-gray-300"> + Dataset Revision (optional) + </Label> + <Input + id="dataset_revision" + value={trainingConfig.dataset_revision || ""} + onChange={(e) => + updateConfig( + "dataset_revision", + e.target.value || undefined + ) + } + placeholder="main" + className="bg-gray-800 border-gray-600 text-white" + /> + <p className="text-xs text-gray-500 mt-1"> + Git revision (branch, tag, or commit hash) + </p> + </div> + + <div> + <Label htmlFor="dataset_root" className="text-gray-300"> + Dataset Root Directory (optional) + </Label> + <Input + id="dataset_root" + value={trainingConfig.dataset_root || ""} + onChange={(e) => + updateConfig("dataset_root", e.target.value || undefined) + } + placeholder="./data" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + </CardContent> + </Card> + + {/* Policy Configuration */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <Cpu className="w-5 h-5" /> + Policy Configuration + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="policy_type" className="text-gray-300"> + Policy Type + </Label> + <Select + value={trainingConfig.policy_type} + onValueChange={(value) => + updateConfig("policy_type", value) + } + > + <SelectTrigger className="bg-gray-800 border-gray-600 text-white"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-600"> + <SelectItem value="act"> + ACT (Action Chunking Transformer) + </SelectItem> + <SelectItem value="diffusion"> + Diffusion Policy + </SelectItem> + <SelectItem value="pi0">PI0</SelectItem> + <SelectItem value="smolvla">SmolVLA</SelectItem> + <SelectItem value="tdmpc">TD-MPC</SelectItem> + <SelectItem value="vqbet">VQ-BeT</SelectItem> + <SelectItem value="pi0fast">PI0 Fast</SelectItem> + <SelectItem value="sac">SAC</SelectItem> + <SelectItem value="reward_classifier"> + Reward Classifier + </SelectItem> + </SelectContent> + </Select> + </div> + + <div> + <Label htmlFor="policy_device" className="text-gray-300"> + Device + </Label> + <Select + value={trainingConfig.policy_device || "cuda"} + onValueChange={(value) => + updateConfig("policy_device", value) + } + > + <SelectTrigger className="bg-gray-800 border-gray-600 text-white"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-600"> + <SelectItem value="cuda">CUDA (GPU)</SelectItem> + <SelectItem value="cpu">CPU</SelectItem> + <SelectItem value="mps">MPS (Apple Silicon)</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="flex items-center space-x-3"> + <Switch + id="policy_use_amp" + checked={trainingConfig.policy_use_amp} + onCheckedChange={(checked) => + updateConfig("policy_use_amp", checked) + } + /> + <Label htmlFor="policy_use_amp" className="text-gray-300"> + Use Automatic Mixed Precision (AMP) + </Label> + </div> + </CardContent> + </Card> + + {/* Training Parameters */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <TrendingUp className="w-5 h-5" /> + Training Parameters + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div> + <Label htmlFor="steps" className="text-gray-300"> + Training Steps + </Label> + <Input + id="steps" + type="number" + value={trainingConfig.steps} + onChange={(e) => + updateConfig("steps", parseInt(e.target.value)) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="batch_size" className="text-gray-300"> + Batch Size + </Label> + <Input + id="batch_size" + type="number" + value={trainingConfig.batch_size} + onChange={(e) => + updateConfig("batch_size", parseInt(e.target.value)) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label htmlFor="seed" className="text-gray-300"> + Random Seed + </Label> + <Input + id="seed" + type="number" + value={trainingConfig.seed || ""} + onChange={(e) => + updateConfig( + "seed", + e.target.value ? parseInt(e.target.value) : undefined + ) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="num_workers" className="text-gray-300"> + Number of Workers + </Label> + <Input + id="num_workers" + type="number" + value={trainingConfig.num_workers} + onChange={(e) => + updateConfig("num_workers", parseInt(e.target.value)) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + </div> + </CardContent> + </Card> + + {/* Optimizer Configuration */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <Settings className="w-5 h-5" /> + Optimizer Configuration + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="optimizer_type" className="text-gray-300"> + Optimizer Type + </Label> + <Select + value={trainingConfig.optimizer_type || "adam"} + onValueChange={(value) => + updateConfig("optimizer_type", value) + } + > + <SelectTrigger className="bg-gray-800 border-gray-600 text-white"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-600"> + <SelectItem value="adam">Adam</SelectItem> + <SelectItem value="adamw">AdamW</SelectItem> + <SelectItem value="sgd">SGD</SelectItem> + <SelectItem value="multi_adam">Multi Adam</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="grid grid-cols-3 gap-4"> + <div> + <Label htmlFor="optimizer_lr" className="text-gray-300"> + Learning Rate + </Label> + <Input + id="optimizer_lr" + type="number" + step="0.0001" + value={trainingConfig.optimizer_lr || ""} + onChange={(e) => + updateConfig( + "optimizer_lr", + e.target.value + ? parseFloat(e.target.value) + : undefined + ) + } + placeholder="Use policy default" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label + htmlFor="optimizer_weight_decay" + className="text-gray-300" + > + Weight Decay + </Label> + <Input + id="optimizer_weight_decay" + type="number" + step="0.0001" + value={trainingConfig.optimizer_weight_decay || ""} + onChange={(e) => + updateConfig( + "optimizer_weight_decay", + e.target.value + ? parseFloat(e.target.value) + : undefined + ) + } + placeholder="Use policy default" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label + htmlFor="optimizer_grad_clip_norm" + className="text-gray-300" + > + Gradient Clipping + </Label> + <Input + id="optimizer_grad_clip_norm" + type="number" + value={trainingConfig.optimizer_grad_clip_norm || ""} + onChange={(e) => + updateConfig( + "optimizer_grad_clip_norm", + e.target.value + ? parseFloat(e.target.value) + : undefined + ) + } + placeholder="Use policy default" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + </div> + </CardContent> + </Card> + + {/* Logging Configuration */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <FileText className="w-5 h-5" /> + Logging & Checkpointing + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-3 gap-4"> + <div> + <Label htmlFor="log_freq" className="text-gray-300"> + Log Frequency + </Label> + <Input + id="log_freq" + type="number" + value={trainingConfig.log_freq} + onChange={(e) => + updateConfig("log_freq", parseInt(e.target.value)) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="save_freq" className="text-gray-300"> + Save Frequency + </Label> + <Input + id="save_freq" + type="number" + value={trainingConfig.save_freq} + onChange={(e) => + updateConfig("save_freq", parseInt(e.target.value)) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="eval_freq" className="text-gray-300"> + Eval Frequency + </Label> + <Input + id="eval_freq" + type="number" + value={trainingConfig.eval_freq} + onChange={(e) => + updateConfig("eval_freq", parseInt(e.target.value)) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + </div> + + <div> + <Label htmlFor="output_dir" className="text-gray-300"> + Output Directory + </Label> + <Input + id="output_dir" + value={trainingConfig.output_dir} + onChange={(e) => updateConfig("output_dir", e.target.value)} + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="job_name" className="text-gray-300"> + Job Name (optional) + </Label> + <Input + id="job_name" + value={trainingConfig.job_name || ""} + onChange={(e) => + updateConfig("job_name", e.target.value || undefined) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div className="flex items-center space-x-3"> + <Switch + id="save_checkpoint" + checked={trainingConfig.save_checkpoint} + onCheckedChange={(checked) => + updateConfig("save_checkpoint", checked) + } + /> + <Label htmlFor="save_checkpoint" className="text-gray-300"> + Save Checkpoints + </Label> + </div> + + <div className="flex items-center space-x-3"> + <Switch + id="resume" + checked={trainingConfig.resume} + onCheckedChange={(checked) => + updateConfig("resume", checked) + } + /> + <Label htmlFor="resume" className="text-gray-300"> + Resume from Checkpoint + </Label> + </div> + </CardContent> + </Card> + + {/* Weights & Biases Configuration */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <TrendingUp className="w-5 h-5" /> + Weights & Biases + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center space-x-3"> + <Switch + id="wandb_enable" + checked={trainingConfig.wandb_enable} + onCheckedChange={(checked) => + updateConfig("wandb_enable", checked) + } + /> + <Label htmlFor="wandb_enable" className="text-gray-300"> + Enable Weights & Biases Logging + </Label> + </div> + + {trainingConfig.wandb_enable && ( + <> + <div> + <Label htmlFor="wandb_project" className="text-gray-300"> + W&B Project Name + </Label> + <Input + id="wandb_project" + value={trainingConfig.wandb_project || ""} + onChange={(e) => + updateConfig( + "wandb_project", + e.target.value || undefined + ) + } + placeholder="my-robotics-project" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="wandb_entity" className="text-gray-300"> + W&B Entity (optional) + </Label> + <Input + id="wandb_entity" + value={trainingConfig.wandb_entity || ""} + onChange={(e) => + updateConfig( + "wandb_entity", + e.target.value || undefined + ) + } + placeholder="your-username" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="wandb_notes" className="text-gray-300"> + W&B Notes (optional) + </Label> + <Input + id="wandb_notes" + value={trainingConfig.wandb_notes || ""} + onChange={(e) => + updateConfig( + "wandb_notes", + e.target.value || undefined + ) + } + placeholder="Training run notes..." + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="wandb_mode" className="text-gray-300"> + W&B Mode + </Label> + <Select + value={trainingConfig.wandb_mode || "online"} + onValueChange={(value) => + updateConfig("wandb_mode", value) + } + > + <SelectTrigger className="bg-gray-800 border-gray-600 text-white"> + <SelectValue /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-600"> + <SelectItem value="online">Online</SelectItem> + <SelectItem value="offline">Offline</SelectItem> + <SelectItem value="disabled">Disabled</SelectItem> + </SelectContent> + </Select> + </div> + + <div className="flex items-center space-x-3"> + <Switch + id="wandb_disable_artifact" + checked={trainingConfig.wandb_disable_artifact} + onCheckedChange={(checked) => + updateConfig("wandb_disable_artifact", checked) + } + /> + <Label + htmlFor="wandb_disable_artifact" + className="text-gray-300" + > + Disable Artifacts + </Label> + </div> + </> + )} + </CardContent> + </Card> + + {/* Environment & Evaluation Configuration */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <Activity className="w-5 h-5" /> + Environment & Evaluation + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="env_type" className="text-gray-300"> + Environment Type (optional) + </Label> + <Select + value={trainingConfig.env_type || "none"} + onValueChange={(value) => + updateConfig( + "env_type", + value === "none" ? undefined : value + ) + } + > + <SelectTrigger className="bg-gray-800 border-gray-600 text-white"> + <SelectValue placeholder="Select environment type" /> + </SelectTrigger> + <SelectContent className="bg-gray-800 border-gray-600"> + <SelectItem value="none">None</SelectItem> + <SelectItem value="aloha">Aloha</SelectItem> + <SelectItem value="pusht">PushT</SelectItem> + <SelectItem value="xarm">XArm</SelectItem> + <SelectItem value="gym_manipulator"> + Gym Manipulator + </SelectItem> + <SelectItem value="hil">HIL</SelectItem> + </SelectContent> + </Select> + </div> + + <div> + <Label htmlFor="env_task" className="text-gray-300"> + Environment Task (optional) + </Label> + <Input + id="env_task" + value={trainingConfig.env_task || ""} + onChange={(e) => + updateConfig("env_task", e.target.value || undefined) + } + placeholder="e.g., insertion_human" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div> + <Label htmlFor="eval_n_episodes" className="text-gray-300"> + Eval Episodes + </Label> + <Input + id="eval_n_episodes" + type="number" + value={trainingConfig.eval_n_episodes} + onChange={(e) => + updateConfig( + "eval_n_episodes", + parseInt(e.target.value) + ) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div> + <Label htmlFor="eval_batch_size" className="text-gray-300"> + Eval Batch Size + </Label> + <Input + id="eval_batch_size" + type="number" + value={trainingConfig.eval_batch_size} + onChange={(e) => + updateConfig( + "eval_batch_size", + parseInt(e.target.value) + ) + } + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + </div> + + <div className="flex items-center space-x-3"> + <Switch + id="eval_use_async_envs" + checked={trainingConfig.eval_use_async_envs} + onCheckedChange={(checked) => + updateConfig("eval_use_async_envs", checked) + } + /> + <Label + htmlFor="eval_use_async_envs" + className="text-gray-300" + > + Use Asynchronous Environments + </Label> + </div> + </CardContent> + </Card> + + {/* Advanced Options */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <Settings className="w-5 h-5" /> + Advanced Options + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <Label htmlFor="config_path" className="text-gray-300"> + Config Path (optional) + </Label> + <Input + id="config_path" + value={trainingConfig.config_path || ""} + onChange={(e) => + updateConfig("config_path", e.target.value || undefined) + } + placeholder="path/to/config.yaml" + className="bg-gray-800 border-gray-600 text-white" + /> + </div> + + <div className="flex items-center space-x-3"> + <Switch + id="use_policy_training_preset" + checked={trainingConfig.use_policy_training_preset} + onCheckedChange={(checked) => + updateConfig("use_policy_training_preset", checked) + } + /> + <Label + htmlFor="use_policy_training_preset" + className="text-gray-300" + > + Use Policy Training Preset + </Label> + </div> + </CardContent> + </Card> + </div> + )} + + {/* Monitoring Tab */} + {activeTab === "monitoring" && ( + <div className="space-y-6"> + {/* Training Progress */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2"> + Training Progress + </h3> + <div className="text-3xl font-bold text-blue-400 mb-2"> + {trainingStatus.current_step} / {trainingStatus.total_steps} + </div> + <Progress value={getProgressPercentage()} className="mb-2" /> + <div className="text-sm text-gray-400"> + {getProgressPercentage().toFixed(1)}% Complete + </div> + </CardContent> + </Card> + + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2">Current Loss</h3> + <div className="text-3xl font-bold text-green-400 mb-2"> + {trainingStatus.current_loss?.toFixed(4) || "N/A"} + </div> + <div className="text-sm text-gray-400">Training Loss</div> + </CardContent> + </Card> + + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2">Learning Rate</h3> + <div className="text-3xl font-bold text-orange-400 mb-2"> + {trainingStatus.current_lr?.toExponential(2) || "N/A"} + </div> + <div className="text-sm text-gray-400">Current LR</div> + </CardContent> + </Card> + + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2">ETA</h3> + <div className="text-3xl font-bold text-purple-400 mb-2"> + {trainingStatus.eta_seconds + ? formatTime(trainingStatus.eta_seconds) + : "N/A"} + </div> + <div className="text-sm text-gray-400">Estimated Time</div> + </CardContent> + </Card> + + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2">Gradient Norm</h3> + <div className="text-3xl font-bold text-cyan-400 mb-2"> + {trainingStatus.grad_norm?.toFixed(3) || "N/A"} + </div> + <div className="text-sm text-gray-400">Gradient Clipping</div> + </CardContent> + </Card> + + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2"> + Training Status + </h3> + <div className="text-2xl font-bold text-yellow-400 mb-2"> + {trainingStatus.training_active ? "Active" : "Stopped"} + </div> + <div className="text-sm text-gray-400">Current State</div> + </CardContent> + </Card> + + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2">Dataset</h3> + <div className="text-lg font-bold text-pink-400 mb-2 truncate"> + {trainingConfig.dataset_repo_id || "Not Set"} + </div> + <div className="text-sm text-gray-400">Repository ID</div> + </CardContent> + </Card> + + <Card className="bg-gray-900 border-gray-700"> + <CardContent className="p-6 text-center"> + <h3 className="text-sm text-gray-400 mb-2">Policy</h3> + <div className="text-lg font-bold text-indigo-400 mb-2 uppercase"> + {trainingConfig.policy_type} + </div> + <div className="text-sm text-gray-400">Model Type</div> + </CardContent> + </Card> + </div> + + {/* Training Logs */} + <Card className="bg-gray-900 border-gray-700"> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-white"> + <FileText className="w-5 h-5" /> + Training Logs + </CardTitle> + </CardHeader> + <CardContent> + <div + ref={logContainerRef} + className="bg-gray-950 rounded-lg p-4 h-96 overflow-y-auto font-mono text-sm" + > + {logs.length === 0 ? ( + <div className="text-gray-500 text-center py-8"> + No training logs yet. Start training to see output. + </div> + ) : ( + logs.map((log, index) => ( + <div key={index} className="mb-1"> + <span className="text-gray-500"> + {new Date(log.timestamp * 1000).toLocaleTimeString()} + </span> + <span className="ml-2 text-gray-300"> + {log.message} + </span> + </div> + )) + )} + </div> + </CardContent> + </Card> + </div> + )} + + {/* Control Buttons */} + <div className="fixed bottom-6 right-6 flex gap-3"> + {!trainingStatus.training_active ? ( + <Button + onClick={handleStartTraining} + disabled={ + isStartingTraining || !trainingConfig.dataset_repo_id.trim() + } + size="lg" + className="bg-green-500 hover:bg-green-600 text-white font-semibold px-8 py-4 text-lg shadow-lg" + > + {isStartingTraining ? ( + <> + <Loader2 className="w-5 h-5 mr-2 animate-spin" /> + Starting... + </> + ) : ( + <> + <Play className="w-5 h-5 mr-2" /> + Start Training + </> + )} + </Button> + ) : ( + <Button + onClick={handleStopTraining} + disabled={!trainingStatus.available_controls.stop_training} + size="lg" + className="bg-red-500 hover:bg-red-600 text-white font-semibold px-8 py-4 text-lg shadow-lg" + > + <Square className="w-5 h-5 mr-2" /> + Stop Training + </Button> + )} + </div> + </div> + </div> + ); +}; + +export default Training; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..11f02fe2a0061d6e6e1f271b21da95423b448b32 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..8706086e12b025e554ac5e9b4c7b07022008921b --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,96 @@ +import type { Config } from "tailwindcss"; + +export default { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + prefix: "", + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + 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))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } + }, + plugins: [require("tailwindcss-animate")], +} satisfies Config; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000000000000000000000000000000000000..0b0e43e6bc8079ff97d899dddb025d5b09f20ee2 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": false, + "noFallthroughCasesInSwitch": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..129b1a30fdbe84d753a2526f13a20e0857f420ba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "noImplicitAny": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "allowJs": true, + "noUnusedLocals": false, + "strictNullChecks": false + } +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000000000000000000000000000000000000..3133162c20350e1741b94b8d2ff77b811bec8363 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..28d31ecbe5c514f36e019ab74fc4301a129599c9 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; +import { componentTagger } from "lovable-tagger"; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => ({ + server: { + host: "::", + port: 8080, + proxy: { + "/start-training": "http://localhost:8000", + "/stop-training": "http://localhost:8000", + "/training-status": "http://localhost:8000", + "/training-logs": "http://localhost:8000", + "/start-recording": "http://localhost:8000", + "/stop-recording": "http://localhost:8000", + "/recording-status": "http://localhost:8000", + "/recording-exit-early": "http://localhost:8000", + "/recording-rerecord-episode": "http://localhost:8000", + "/start-calibration": "http://localhost:8000", + "/stop-calibration": "http://localhost:8000", + "/calibration-status": "http://localhost:8000", + "/calibration-input": "http://localhost:8000", + "/calibration-debug": "http://localhost:8000", + "/calibration-configs": "http://localhost:8000", + "/move-arm": "http://localhost:8000", + "/stop-teleoperation": "http://localhost:8000", + "/teleoperation-status": "http://localhost:8000", + "/joint-positions": "http://localhost:8000", + "/get-configs": "http://localhost:8000", + "/health": "http://localhost:8000", + "/ws": { + target: "ws://localhost:8000", + ws: true, + }, + }, + }, + plugins: [react(), mode === "development" && componentTagger()].filter( + Boolean + ), + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}));