Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea083485d5 | ||
|
|
0c7504b841 | ||
|
|
aba0964a59 | ||
|
|
86f0acc26b | ||
|
|
004fcc59df | ||
|
|
5366865b4b | ||
| 97d2b66f02 | |||
| 4bc0fd203f |
174
.github/workflows/check-vendor.yml
vendored
Normal file
174
.github/workflows/check-vendor.yml
vendored
Normal file
@@ -0,0 +1,174 @@
|
||||
name: Check Vendor Updates
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
# Allow manual triggering
|
||||
|
||||
jobs:
|
||||
check-vendor:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd scripts
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Generate manifest from site data
|
||||
run: |
|
||||
python scripts/vendor_update.py --scan-only
|
||||
|
||||
- name: Check for updates
|
||||
id: check-updates
|
||||
env:
|
||||
GITHUB_API_TOKEN: ${{ secrets.GITHUB_API_TOKEN }}
|
||||
run: |
|
||||
python scripts/check_updates.py \
|
||||
--manifest manifest/vendor_manifest.json \
|
||||
--output report.json || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Read update report
|
||||
id: read-report
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f report.json ]; then
|
||||
OUT_OF_DATE=$(python -c "import json; r=json.load(open('report.json')); print(r.get('out_of_date', 0))")
|
||||
echo "out_of_date=$OUT_OF_DATE" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=$([ $OUT_OF_DATE -gt 0 ] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "out_of_date=0" >> $GITHUB_OUTPUT
|
||||
echo "has_updates=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get out-of-date entry IDs
|
||||
id: get-entries
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
python -c "
|
||||
import json
|
||||
with open('report.json') as f:
|
||||
report = json.load(f)
|
||||
entries = [e['id'] for e in report['entries'] if e.get('status') == 'out-of-date']
|
||||
entry_ids = ','.join(entries)
|
||||
print(f'entry_ids={entry_ids}')
|
||||
" >> $GITHUB_OUTPUT || echo "entry_ids=" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create update branch
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
ENTRY_IDS=$(echo "${{ steps.get-entries.outputs.entry_ids }}" | tr ',' '-' | cut -c1-50)
|
||||
BRANCH_NAME="vendor-update/${TIMESTAMP}-${ENTRY_IDS}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Update vendored files
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
env:
|
||||
GITHUB_API_TOKEN: ${{ secrets.GITHUB_API_TOKEN }}
|
||||
run: |
|
||||
ENTRY_IDS="${{ steps.get-entries.outputs.entry_ids }}"
|
||||
for entry_id in $(echo "$ENTRY_IDS" | tr ',' ' '); do
|
||||
echo "Updating entry: $entry_id"
|
||||
python scripts/vendor_update.py \
|
||||
--manifest manifest/vendor_manifest.json \
|
||||
--entry "$entry_id" \
|
||||
--sync-site
|
||||
done
|
||||
|
||||
- name: Run site build (if available)
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
if [ -f website/package.json ]; then
|
||||
cd website
|
||||
npm ci || npm install
|
||||
npm run build || echo "Build failed but continuing..."
|
||||
else
|
||||
echo "No website build step found, skipping..."
|
||||
fi
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
run: |
|
||||
git add manifest/vendor_manifest.json vendor/ website/src/data/components/
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: update vendored files
|
||||
|
||||
Updated $(echo "${{ steps.get-entries.outputs.entry_ids }}" | tr ',' ' ' | wc -w) vendored file(s):
|
||||
$(echo "${{ steps.get-entries.outputs.entry_ids }}" | tr ',' '\n' | sed 's/^/ - /')
|
||||
|
||||
Auto-generated by check-vendor workflow"
|
||||
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.read-report.outputs.has_updates == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: ${{ env.BRANCH_NAME }}
|
||||
title: "chore: Update vendored files"
|
||||
body: |
|
||||
## Vendor Update
|
||||
|
||||
This PR updates vendored files that have changed upstream.
|
||||
|
||||
**Updated entries:**
|
||||
${{ steps.get-entries.outputs.entry_ids }}
|
||||
|
||||
**Report:**
|
||||
- Total entries checked: ${{ steps.read-report.outputs.out_of_date }}
|
||||
- Out-of-date entries: ${{ steps.read-report.outputs.out_of_date }}
|
||||
|
||||
### Changes
|
||||
- Updated manifest with new commit SHAs
|
||||
- Downloaded latest versions of changed files
|
||||
- Synced vendor metadata to site component JSON files
|
||||
|
||||
### Verification
|
||||
- [ ] Manifest updated correctly
|
||||
- [ ] Files downloaded and checksums verified
|
||||
- [ ] Site JSON files updated with vendor metadata
|
||||
- [ ] Site build passes (if applicable)
|
||||
|
||||
---
|
||||
*This PR was automatically created by the check-vendor workflow.*
|
||||
labels: |
|
||||
automated
|
||||
vendor-update
|
||||
draft: false
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ "${{ steps.read-report.outputs.has_updates }}" == "true" ]; then
|
||||
echo "## ✅ Updates Available" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Found ${{ steps.read-report.outputs.out_of_date }} out-of-date entries." >> $GITHUB_STEP_SUMMARY
|
||||
echo "Created PR: vendor-update/${{ env.BRANCH_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## ✅ All Up-to-Date" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All vendored files are up-to-date with upstream." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
152
CONTRIBUTING.md
Normal file
152
CONTRIBUTING.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 🛠️ Contributing to OSSM Configurator
|
||||
|
||||
Thank you for your interest in contributing to the OSSM Configurator! This document provides a detailed guide on how to add new components, mods, remotes, and hardware to the system.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Data Architecture Overview
|
||||
|
||||
The configurator's data is organized into several key directories within `website/src/data/`:
|
||||
|
||||
- `common/`: Shared data like `colors.json` and `hardware.json`.
|
||||
- `components/`: Detailed definitions of physical parts (STLs, quantities, dependencies).
|
||||
- `config/`: Wizard configuration, including `options.json` which defines the UI structure.
|
||||
|
||||
---
|
||||
|
||||
## ➕ Adding a New Component (Mod, Remote, or Part)
|
||||
|
||||
Adding a component involves three main steps:
|
||||
1. Defining the physical parts in `components/`.
|
||||
2. Ensuring all required hardware exists in `common/hardware.json`.
|
||||
3. Linking the component into the wizard UI via `config/options.json`.
|
||||
|
||||
### Step 1: Define the Component
|
||||
Components are defined as JSON objects. A component can use one of two structures:
|
||||
|
||||
#### A. Standard Component (e.g., `actuator.json`)
|
||||
Used for fixed assemblies where all selected parts are gathered into a single list.
|
||||
|
||||
```json
|
||||
{
|
||||
"my-new-mod": {
|
||||
"category": "Mods",
|
||||
"type": "mod",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "mod-part-a",
|
||||
"name": "Mod Part A",
|
||||
"filamentEstimate": 45.2,
|
||||
"timeEstimate": "1h30m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"url": "https://github.com/Owner/Repo/blob/main/part-a.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m3x8-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": ["mod-part-a"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### B. System-based Component (e.g., `remote.json`, `hinges.json`)
|
||||
Used when a category has multiple distinct "systems" that a user chooses between.
|
||||
|
||||
```json
|
||||
{
|
||||
"remotes": {
|
||||
"category": "Remote",
|
||||
"systems": {
|
||||
"my-custom-remote": {
|
||||
"name": "Custom Remote v1",
|
||||
"description": "A high-performance custom remote",
|
||||
"image": "/images/options/custom-remote.png",
|
||||
"bodyParts": [
|
||||
{
|
||||
"id": "remote-shell",
|
||||
"name": "Remote Shell",
|
||||
"url": "...",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"knobs": [
|
||||
{ "id": "knob-standard", "name": "Standard Knob", "url": "..." }
|
||||
],
|
||||
"hardwareParts": [
|
||||
{ "id": "remote-hardware", "required": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Register Hardware
|
||||
If your component requires hardware not already in the system, add it to `website/src/data/common/hardware.json`:
|
||||
|
||||
```json
|
||||
"fasteners": {
|
||||
"M3x12 SHCS": {
|
||||
"id": "hardware-fasteners-m3x12-shcs",
|
||||
"name": "M3x12 SHCS",
|
||||
"price": 0.15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Add to the Wizard (options.json)
|
||||
To make your part selectable, add its ID to the `sections` in `website/src/data/config/options.json`.
|
||||
|
||||
```json
|
||||
"toyMounts": {
|
||||
"sections": {
|
||||
"myNewCategory": {
|
||||
"title": "My New Category",
|
||||
"componentIds": ["my-new-part-id"],
|
||||
"isMultiSelect": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 The Vendor System
|
||||
|
||||
The configurator uses a script to "vendor" external files. This ensures that even if an upstream GitHub repo changes, our builds remain stable.
|
||||
|
||||
After adding a new component with a `url` field:
|
||||
1. Run the vendor script:
|
||||
```bash
|
||||
python scripts/vendor_update.py
|
||||
```
|
||||
2. The script will:
|
||||
- Download the file to the `vendor/` directory.
|
||||
- Calculate a checksum.
|
||||
- Pin it to the current commit SHA.
|
||||
- Update your component JSON with a `vendor` metadata block.
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ Images & Assets
|
||||
|
||||
- **Component Images**: Place images in `website/public/images/options/`.
|
||||
- **Naming**: Use the component `id` as the filename (e.g., `my-part-id.png`).
|
||||
- **Specs**: Transparent PNGs are preferred for a premium "floating" look.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Contribution Checklist
|
||||
|
||||
1. [ ] Component JSON added to `website/src/data/components/`.
|
||||
2. [ ] Hardware added to `common/hardware.json` (if new).
|
||||
3. [ ] ID added to `config/options.json`.
|
||||
4. [ ] `python scripts/vendor_update.py` executed.
|
||||
5. [ ] Verified that the part appears in the summary and correctly calculates hardware.
|
||||
6. [ ] (Optional) High-quality image added to `/public/images/options/`.
|
||||
255
README.md
255
README.md
@@ -1,216 +1,111 @@
|
||||
# OSSM Configurator
|
||||
# 🛠️ OSSM Configurator
|
||||
|
||||
A web-based configuration tool for the Open Source Sex Machine (OSSM) project. This application provides an intuitive wizard interface that guides users through selecting and customizing components for their OSSM build, generating a complete Bill of Materials (BOM) and configuration summary.
|
||||
[](https://github.com/KinkyMakers/OSSM-Configurator)
|
||||
[](https://github.com/KinkyMakers/OSSM-Configurator)
|
||||
|
||||
## Project Structure
|
||||
A professional web-based configuration tool for the **Open Source Sex Machine (OSSM)** project. This application provides a premium, intuitive wizard interface that guides users through the complex process of selecting, customizing, and validating components for their OSSM build.
|
||||
|
||||
```
|
||||
OSSM-Configurator/
|
||||
├── website/ # Main web application
|
||||
│ ├── src/ # React source code
|
||||
│ ├── public/ # Static assets (images, etc.)
|
||||
│ ├── dist/ # Build output (generated)
|
||||
│ ├── node_modules/ # Dependencies (generated)
|
||||
│ └── ... # Configuration files
|
||||
├── BOM.xlsx # Bill of Materials spreadsheet
|
||||
├── Screen Shots/ # Application screenshots
|
||||
└── README.md # This file
|
||||
```
|
||||
---
|
||||
|
||||
## Website Overview
|
||||
## 🌟 Key Features
|
||||
|
||||
The OSSM Configurator is a React-based single-page application built with Vite. It provides a step-by-step wizard interface that allows users to:
|
||||
- **Intuitive Wizard Flow**: Step-by-step guidance from motor selection to final export.
|
||||
- **Dynamic 3D Visualization (Coming Soon)**: Preview your build with real-time color updates.
|
||||
- **Smart Compatibility**: Ensures your PSU matches your Motor and your Mount fits your Actuator.
|
||||
- **Rich BOM Export**: Download a complete ZIP package containing:
|
||||
- 📄 **README.md** overview of your build.
|
||||
- 📊 **Excel BOM** for hardware ordering.
|
||||
- 🖨️ **Print List** with filament estimates.
|
||||
- 📁 **Organized STL Files** categorized by component and color.
|
||||
- **Vendor System**: Integrated tracking for external CAD files to ensure reproducible builds.
|
||||
- **Dark Mode Support**: A premium aesthetic designed for builders, day or night.
|
||||
|
||||
1. **Select Motor** - Choose from available motor options (42AIM30, 57AIM30, iHSV57)
|
||||
2. **Choose Power Supply** - Select appropriate power supply (24V PSU, 24V USB-C PD)
|
||||
3. **Customize Colors** - Pick primary and accent colors for 3D printed parts
|
||||
4. **Configure Options** - Select mounting options, stands, toy mounts, actuators, and other components
|
||||
5. **Review Summary** - View complete BOM with pricing, filament estimates, and export options
|
||||
---
|
||||
|
||||
### Key Features
|
||||
## 📸 Guided Walkthrough
|
||||
|
||||
- **Interactive Wizard Interface**: Step-by-step configuration process with progress tracking
|
||||
- **Component Compatibility**: Ensures selected components are compatible with each other
|
||||
- **Real-time Pricing**: Calculates total cost including hardware and printed parts
|
||||
- **Filament Estimates**: Provides 3D printing filament requirements for each component
|
||||
- **BOM Export**: Generate and download a complete Bill of Materials
|
||||
- **Visual Component Selection**: Image-based component selection for better user experience
|
||||
### 1. Component Selection
|
||||
The wizard starts with core hardware. Select your motor and power supply with real-time feedback on compatibility and cost.
|
||||
|
||||
### Technology Stack
|
||||

|
||||
*Figure 1: Motor selection with clear technical specs and visual feedback.*
|
||||
|
||||
- **React 18** - UI framework
|
||||
- **Vite** - Build tool and dev server
|
||||
- **Tailwind CSS** - Styling
|
||||
- **JSZip** - For generating downloadable BOM packages
|
||||

|
||||
*Figure 2: Choosing a compatible power supply (24V or USB-C PD).*
|
||||
|
||||
## TODO
|
||||
- [X] Dark Mode [Completed]
|
||||
- [ ] Finalize Actuator Components and mapping to BOM [In Progress]
|
||||
- [ ] Finalize Stand Components and mapping to BOM
|
||||
- [ ] Finalize PCB Components and mapping to BOM
|
||||
- [ ] Finalize Toy Mounts Components and mapping to BOM
|
||||
- [ ] Finalize Remote Control Components and mapping to BOM
|
||||
- [ ] Finalize Mounting Components and mapping to BOM
|
||||
- [ ] Finalize Other Components and mapping to BOM
|
||||
- [ ] Finalize Colors and mapping to BOM
|
||||
- [ ] Finalize Pricing and mapping to BOM
|
||||
- [ ] Finalize BOM Export and mapping to BOM
|
||||
- [ ] Finalize BOM Import and mapping to BOM
|
||||
- [ ] Finalize Storage and sharing of BOMs
|
||||
- [ ] Add references to original hardware files and designs
|
||||
- [ ] Add Readme/assembly instructions for each component
|
||||
- [ ] Add FAQ and troubleshooting guide
|
||||
- [ ] Add support for multiple languages
|
||||
- [ ] Add support for multiple currencies
|
||||
- [ ] Add support for multiple payment methods
|
||||
- [ ] Add support for multiple shipping methods
|
||||
- [ ] Add support for multiple shipping countries
|
||||
- [ ] Add support for multiple shipping regions
|
||||
- [ ] Add support for multiple shipping cities
|
||||
- [ ] Add 3D render of final product with all components and options selected and coloured [If possible]
|
||||
## Getting Started
|
||||
### 2. Aesthetic Customization
|
||||
Choose your Primary and Accent colors. These selections automatically update the filament estimates in your final BOM.
|
||||
|
||||
### Prerequisites
|
||||

|
||||
*Figure 3: Interactive color picker for 3D printed components.*
|
||||
|
||||
**Option 1: Using Docker (Recommended)**
|
||||
- Docker Desktop or Docker Engine
|
||||
- Docker Compose
|
||||
### 3. Detailed Options
|
||||
Configure every aspect of your machine, from the stand type to specialized toy mounts.
|
||||
|
||||
**Option 2: Local Development**
|
||||
- Node.js (v16 or higher recommended)
|
||||
- npm or yarn
|
||||

|
||||
*Figure 4: Browsing the extensive list of compatible add-ons and variations.*
|
||||
|
||||
### Installation
|
||||
### 4. Final Summary & Export
|
||||
Review your entire build, total cost, and total filament weight before exporting your build package.
|
||||
|
||||
1. Navigate to the website directory:
|
||||
```bash
|
||||
cd website
|
||||
```
|
||||

|
||||
*Figure 5: The comprehensive BOM summary with automated ZIP generation.*
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
---
|
||||
|
||||
### Development
|
||||
## 🚀 Getting Started
|
||||
|
||||
Run the development server:
|
||||
### Quick Start (Docker)
|
||||
The easiest way to run the configurator locally:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:5173` (or the port shown in the terminal).
|
||||
|
||||
### Building for Production
|
||||
|
||||
Create an optimized production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The built files will be in the `website/dist/` directory.
|
||||
|
||||
### Preview Production Build
|
||||
|
||||
Preview the production build locally:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Development with Docker Compose
|
||||
|
||||
Run the application in development mode with hot reload:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose-dev.yml up
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:5173` with hot module replacement enabled.
|
||||
|
||||
To run in detached mode (background):
|
||||
```bash
|
||||
docker-compose -f docker-compose-dev.yml up -d
|
||||
```
|
||||
Access the app at `http://localhost:5173`.
|
||||
|
||||
To stop the development container:
|
||||
```bash
|
||||
docker-compose -f docker-compose-dev.yml down
|
||||
```
|
||||
### Local Development
|
||||
1. **Clone the repo**
|
||||
2. **Setup Website**:
|
||||
```bash
|
||||
cd website
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
3. **Setup Logic** (Optional, for vendor updates):
|
||||
```bash
|
||||
cd scripts
|
||||
pip install -r requirements.txt
|
||||
python vendor_update.py
|
||||
```
|
||||
|
||||
### Production with Docker Compose
|
||||
---
|
||||
|
||||
Build and run the production image:
|
||||
## 🗺️ Project Roadmap
|
||||
|
||||
```bash
|
||||
docker-compose up --build -d
|
||||
```
|
||||
We are constantly improving the OSSM Configurator. Check out our **[detailed roadmap](./roadmap/ROADMAP.md)** for upcoming features, including:
|
||||
- 🛠️ Interactive 3D Render/Preview
|
||||
- 🌍 Multi-language & Multi-currency support
|
||||
- 📦 Integrated assembly guides
|
||||
|
||||
The application will be available at `http://localhost:80`
|
||||
---
|
||||
|
||||
To run without rebuilding (if image already exists):
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
## 🤝 Contributing
|
||||
|
||||
To stop the production container:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
Contributions are welcome! Whether you are adding a new remote, a toy mount, or a hardware mod, please refer to our detailed guide:
|
||||
|
||||
To view logs:
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
👉 **[Read the CONTRIBUTING.md](./CONTRIBUTING.md)**
|
||||
|
||||
### Using Pre-built Docker Images
|
||||
### Quick Summary:
|
||||
1. Update JSON data in `website/src/data/`.
|
||||
2. Add relevant images to `website/public/images/`.
|
||||
3. Run `python scripts/vendor_update.py` for new files.
|
||||
4. Submit a PR!
|
||||
|
||||
The project includes GitHub Actions workflows that automatically build and publish Docker images to GitHub Container Registry (ghcr.io) on releases. You can pull and run the latest release image:
|
||||
---
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/<your-username>/<your-repo-name>:V0.0.1-BETA
|
||||
docker run -d -p 80:80 ghcr.io/<your-username>/<your-repo-name>:V0.0.1-BETA
|
||||
```
|
||||
## 📜 License
|
||||
|
||||
## Configuration Data
|
||||
This project is part of the **Open Source Sex Machine (OSSM)** project. Please refer to the main OSSM project for full license details.
|
||||
|
||||
The application uses JSON data files located in `website/src/data/`:
|
||||
|
||||
- `motors.json` - Available motor options
|
||||
- `powerSupplies.json` - Power supply options
|
||||
- `colors.json` - Available color options
|
||||
- `options.json` - Main configuration options
|
||||
- `components/` - Detailed component data:
|
||||
- `actuator.json` - Actuator components
|
||||
- `mounting.json` - Mounting options
|
||||
- `remote.json` - Remote control components
|
||||
- `stand.json` - Stand components
|
||||
- `toyMounts.json` - Toy mount options
|
||||
|
||||
## Project Purpose
|
||||
|
||||
The OSSM Configurator serves as a comprehensive tool for users building their own Open Source Sex Machine. It simplifies the configuration process by:
|
||||
|
||||
- **Guiding Selection**: Step-by-step wizard prevents missing critical components
|
||||
- **Ensuring Compatibility**: Validates component combinations
|
||||
- **Providing Transparency**: Shows costs, filament requirements, and time estimates
|
||||
- **Generating Documentation**: Creates exportable BOM for ordering parts and printing
|
||||
|
||||
This tool is essential for both beginners and experienced builders who want to ensure they have all necessary components and understand the full scope of their build before starting.
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new components or options:
|
||||
|
||||
1. Update the appropriate JSON data files in `website/src/data/`
|
||||
2. Add corresponding images to `website/public/images/`
|
||||
3. Test the configuration flow to ensure compatibility
|
||||
4. Update component pricing and filament estimates as needed
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the Open Source Sex Machine (OSSM) project. Please refer to the OSSM project license for usage terms.
|
||||
---
|
||||
*Built with ❤️ by the OSSM Community.*
|
||||
|
||||
180
api/github_webhook/index.py
Executable file
180
api/github_webhook/index.py
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitHub webhook handler for detecting upstream changes.
|
||||
|
||||
Receives push events from GitHub and triggers vendor update checks
|
||||
for affected repositories and paths.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def verify_signature(payload_body: bytes, signature_header: str, secret: str) -> bool:
|
||||
"""Verify GitHub webhook signature."""
|
||||
if not secret:
|
||||
return False
|
||||
|
||||
hash_object = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
msg=payload_body,
|
||||
digestmod=hashlib.sha256
|
||||
)
|
||||
expected_signature = "sha256=" + hash_object.hexdigest()
|
||||
|
||||
return hmac.compare_digest(expected_signature, signature_header)
|
||||
|
||||
|
||||
def load_manifest(manifest_path: Path) -> List[Dict]:
|
||||
"""Load vendor manifest."""
|
||||
if not manifest_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict) and 'entries' in data:
|
||||
return data['entries']
|
||||
return []
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return []
|
||||
|
||||
|
||||
def find_affected_entries(push_event: Dict, manifest: List[Dict]) -> List[str]:
|
||||
"""
|
||||
Find manifest entries affected by a push event.
|
||||
Returns list of manifest entry IDs.
|
||||
"""
|
||||
affected = []
|
||||
|
||||
repo_full_name = push_event.get('repository', {}).get('full_name')
|
||||
if not repo_full_name:
|
||||
return affected
|
||||
|
||||
commits = push_event.get('commits', [])
|
||||
changed_files = set()
|
||||
|
||||
for commit in commits:
|
||||
changed_files.update(commit.get('added', []))
|
||||
changed_files.update(commit.get('modified', []))
|
||||
changed_files.update(commit.get('removed', []))
|
||||
|
||||
# Match changed files against manifest entries
|
||||
for entry in manifest:
|
||||
source_repo = entry.get('source_repo')
|
||||
source_path = entry.get('source_path')
|
||||
|
||||
if source_repo == repo_full_name and source_path in changed_files:
|
||||
affected.append(entry['id'])
|
||||
|
||||
return affected
|
||||
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
"""Handle GitHub webhook POST requests."""
|
||||
# Get webhook secret from environment
|
||||
webhook_secret = os.getenv('WEBHOOK_SECRET')
|
||||
|
||||
# Verify signature if secret is configured
|
||||
if webhook_secret:
|
||||
signature = request.headers.get('X-Hub-Signature-256', '')
|
||||
if not verify_signature(request.data, signature, webhook_secret):
|
||||
return jsonify({'error': 'Invalid signature'}), 401
|
||||
|
||||
# Parse event
|
||||
try:
|
||||
event = request.json
|
||||
event_type = request.headers.get('X-GitHub-Event', '')
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Invalid JSON: {e}'}), 400
|
||||
|
||||
# Only process push events
|
||||
if event_type != 'push':
|
||||
return jsonify({'message': f'Ignoring event type: {event_type}'}), 200
|
||||
|
||||
# Load manifest
|
||||
script_dir = Path(__file__).parent.parent.parent
|
||||
manifest_path = script_dir / 'manifest' / 'vendor_manifest.json'
|
||||
manifest = load_manifest(manifest_path)
|
||||
|
||||
# Find affected entries
|
||||
affected_ids = find_affected_entries(event, manifest)
|
||||
|
||||
if not affected_ids:
|
||||
return jsonify({
|
||||
'message': 'No affected manifest entries',
|
||||
'repo': event.get('repository', {}).get('full_name')
|
||||
}), 200
|
||||
|
||||
# Log the event
|
||||
repo_name = event.get('repository', {}).get('full_name', 'unknown')
|
||||
print(f"Webhook: Push event for {repo_name} affects {len(affected_ids)} entries: {affected_ids}")
|
||||
|
||||
# Trigger check/update flow
|
||||
# In a production environment, you might want to enqueue this as a background job
|
||||
# For now, we'll just log and optionally run check_updates.py
|
||||
|
||||
try:
|
||||
# Run check_updates.py to see what needs updating
|
||||
check_script = script_dir / 'scripts' / 'check_updates.py'
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(check_script), '--manifest', str(manifest_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({
|
||||
'message': 'Update check completed',
|
||||
'affected_entries': affected_ids,
|
||||
'check_output': result.stdout
|
||||
}), 200
|
||||
else:
|
||||
# Some entries are out-of-date
|
||||
return jsonify({
|
||||
'message': 'Updates available',
|
||||
'affected_entries': affected_ids,
|
||||
'check_output': result.stdout,
|
||||
'stderr': result.stderr
|
||||
}), 200
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'message': 'Update check timed out',
|
||||
'affected_entries': affected_ids
|
||||
}), 500
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'error': f'Failed to run update check: {e}',
|
||||
'affected_entries': affected_ids
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# For local development
|
||||
port = int(os.getenv('PORT', 5000))
|
||||
app.run(host='0.0.0.0', port=port, debug=os.getenv('FLASK_DEBUG', 'false').lower() == 'true')
|
||||
else:
|
||||
# For serverless/production deployment (e.g., AWS Lambda, Google Cloud Functions)
|
||||
# Export the Flask app
|
||||
pass
|
||||
575
manifest/vendor_manifest.json
Normal file
575
manifest/vendor_manifest.json
Normal file
@@ -0,0 +1,575 @@
|
||||
[
|
||||
{
|
||||
"id": "handle-spacer",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "handle-spacer",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"checksum_sha256": "55ede7dff60a31d68159b352b5f2c63792b7a0dbe9d543a43681c3e52d229115",
|
||||
"last_checked": "2026-01-07T06:30:07.525364+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"checksum_sha256": "457a71bc09cb53f12026fd829bec8fa5b04fdead0788822935780f42c90b9a7a",
|
||||
"last_checked": "2026-01-07T06:30:08.525159+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-end-effector",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-24mm-clamping-thread-end-effector",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"checksum_sha256": "4860947b201e2e773b295d33bba09423ae40b4adeef3605d62687f2d40277de1",
|
||||
"last_checked": "2026-01-07T06:30:09.547007+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-nut-5-sided",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-24mm-nut-5-sided",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"checksum_sha256": "38630c70b2fb929bba9a705dabf5bbd7b49ec882963e042b7108dc74284dd6ff",
|
||||
"last_checked": "2026-01-07T06:30:10.564924+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-3030-cap",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "ossm-3030-cap",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"checksum_sha256": "56fa9bb318cdeadc6d1698a1e6cef9371e58b0bc9c7729985bf639d8da2f25da",
|
||||
"last_checked": "2026-01-07T06:30:11.578686+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-bottom",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-actuator-body-bottom",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"checksum_sha256": "e7abdb99a7e9b9e7408a7b04a7dd50e42cc74510ea2969016a45a2a1387dcde3",
|
||||
"last_checked": "2026-01-07T06:30:14.604915+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-cover",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-actuator-body-cover",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"checksum_sha256": "bbabc742d2f1753d1b4e21e42c197aec31a4a083b5c634e6e825cec69d4e3258",
|
||||
"last_checked": "2026-01-07T06:30:18.689516+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-middle",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-actuator-body-middle",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"checksum_sha256": "ce6fb769378636c287af788ce42bdab1f2185dcffba929a0c72598742793b48a",
|
||||
"last_checked": "2026-01-07T06:30:22.906540+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-middle-pivot",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"orig_site_json": "website/src/data/components/mounting/ossm.json",
|
||||
"orig_item_id": "ossm-actuator-body-middle-pivot",
|
||||
"pinned_sha": "ad39a03b628b8e38549b99036c8dfd4131948545",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/ad39a03b628b8e38549b99036c8dfd4131948545/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"checksum_sha256": "f6403a3c53e0d8c8e63d48bf853ab17c9f283421b1665b5503dbb04d59d0f52d",
|
||||
"last_checked": "2026-01-07T07:29:55.699272+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/ad39a03b628b8e38549b99036c8dfd4131948545/LICENCE",
|
||||
"upstream_latest_sha": "ad39a03b628b8e38549b99036c8dfd4131948545"
|
||||
},
|
||||
{
|
||||
"id": "ossm-belt-tensioner",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"orig_site_json": "website/src/data/components/actuator.json",
|
||||
"orig_item_id": "ossm-belt-tensioner",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"checksum_sha256": "31c74250c237763b0013ff42cc714ce14c293382a726de363f1686a7559f525f",
|
||||
"last_checked": "2026-01-07T07:29:56.698523+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-handle-spacer",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"orig_site_json": "website/src/data/components/mounting/ossm.json",
|
||||
"orig_item_id": "ossm-handle-spacer",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"checksum_sha256": "55ede7dff60a31d68159b352b5f2c63792b7a0dbe9d543a43681c3e52d229115",
|
||||
"last_checked": "2026-01-07T07:29:59.629891+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/PCB/OSSM - PCB - 3030 Mount.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount.stl",
|
||||
"orig_site_json": "website/src/data/components/pcb/ossm.json",
|
||||
"orig_item_id": "ossm-pcb-3030-mount",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount-cover",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/PCB/OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"orig_site_json": "website/src/data/components/pcb/ossm.json",
|
||||
"orig_item_id": "ossm-pcb-3030-mount-cover",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-aio-backpack-base",
|
||||
"source_repo": "armpitMFG/OSSM-Parts",
|
||||
"source_path": "OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Base V2.3c.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/armpitMFG-OSSM-Parts/OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Base V2.3c.stl",
|
||||
"orig_site_json": "website/src/data/components/pcb/armpitmfg.json",
|
||||
"orig_item_id": "ossm-pcb-aio-backpack-base",
|
||||
"status": "pending",
|
||||
"pinned_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-aio-backpack-cap",
|
||||
"source_repo": "armpitMFG/OSSM-Parts",
|
||||
"source_path": "OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Cap (Default).stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/armpitMFG-OSSM-Parts/OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Cap (Default).stl",
|
||||
"orig_site_json": "website/src/data/components/pcb/armpitmfg.json",
|
||||
"orig_item_id": "ossm-pcb-aio-backpack-cap",
|
||||
"status": "pending",
|
||||
"pinned_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-body",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-body",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"checksum_sha256": "a0c3bb663a4bba6876a89c8e0dce81d0c6c673c9fc1f4537537000576f7f9e48",
|
||||
"last_checked": "2026-01-07T09:37:56.320094+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"checksum_sha256": "3dcf73220cecd534ea9db0d991cec1001b4495f0b9d98f71f5b0b8c68b780439",
|
||||
"last_checked": "2026-01-07T09:37:59.299155+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob-knurled",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"checksum_sha256": "43f68e9467d4da82e6e9aaa545e9d9eda19ef60f8173ccd7fab7fa0835e770d4",
|
||||
"last_checked": "2026-01-07T09:38:02.349095+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"checksum_sha256": "4d06b58617b70633610c4d6fc8441eff45e751b904d7b0ecd442ea97a8bfa2a6",
|
||||
"last_checked": "2026-01-07T09:38:05.401527+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"checksum_sha256": "b1816680cc49d5afe57b5d4f5dabab56b5446a429d43c94d184892365bfa9330",
|
||||
"last_checked": "2026-01-07T09:38:08.443214+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-top-cover",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"orig_site_json": "website/src/data/components/remote.json",
|
||||
"orig_item_id": "ossm-remote-top-cover",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"checksum_sha256": "890a6b117dc6fd306b7523838ad81ae32ad4e642c90179a58d9f313b3f3c783e",
|
||||
"last_checked": "2026-01-07T09:38:11.540477+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-24mm-threaded",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Double Double 24mm Threaded.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Double Double 24mm Threaded.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-double-double-24mm-threaded",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-rail-mounted",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Double Double Rail Mounted.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Double Double Rail Mounted.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-double-double-rail-mounted",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-24mm-threaded",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base 24mm Threaded.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base 24mm Threaded.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-flange-base-24mm-threaded",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2.5in ",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base Dildo Ring 2.5in.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base Dildo Ring 2.5in.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-flange-base-dildo-ring-2.5in ",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2in",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base Dildo Ring 2in.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base Dildo Ring 2in.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-flange-base-dildo-ring-2in",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Base Plate 24mm Threaded.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Base Plate 24mm Threaded.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-ring-insert-55mm",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Ring Insert 55mm.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Ring Insert 55mm.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-sucson-mount-ring-insert-55mm",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-threaded-ring",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Threaded Ring.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Threaded Ring.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-sucson-mount-threaded-ring",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-110mm",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Tie Down and Suction Plate 110mm.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Tie Down and Suction Plate 110mm.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-tie-down-and-suction-plate-110mm",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-135mm",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Toy Mounts/OSSM - Toy Mount Tie Down and Suction Plate 135mm.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Tie Down and Suction Plate 135mm.stl",
|
||||
"orig_site_json": "website/src/data/components/toyMounts/ossm.json",
|
||||
"orig_item_id": "ossm-toy-mount-tie-down-and-suction-plate-135mm",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error",
|
||||
"license": null,
|
||||
"upstream_latest_sha": null
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-base-ossm-v2",
|
||||
"source_repo": "armpitMFG/PitClamp-Mini",
|
||||
"source_path": "Files/Bases/PitClamp Mini - Base - Modular - OSSM v2.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/armpitMFG-PitClamp-Mini/Files/Bases/PitClamp Mini - Base - Modular - OSSM v2.stl",
|
||||
"orig_site_json": "website/src/data/components/mounting/armpitmfg.json",
|
||||
"orig_item_id": "pitclamp-mini-base-ossm-v2",
|
||||
"status": "pending",
|
||||
"pinned_sha": null
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-rail-clamp",
|
||||
"source_repo": "armpitMFG/PitClamp-Mini",
|
||||
"source_path": "Files/Rail Components/PitClamp Mini - Rail - Standard Mod - Clamp V1.1.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/armpitMFG-PitClamp-Mini/Files/Rail Components/PitClamp Mini - Rail - Standard Mod - Clamp V1.1.stl",
|
||||
"orig_site_json": "website/src/data/components/mounting/armpitmfg.json",
|
||||
"orig_item_id": "pitclamp-mini-rail-clamp",
|
||||
"status": "pending",
|
||||
"pinned_sha": null
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-rail-pivot",
|
||||
"source_repo": "armpitMFG/PitClamp-Mini",
|
||||
"source_path": "Files/Rail Components/PitClamp Mini - Rail - Standard Mod - Pivot V1.1.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/armpitMFG-PitClamp-Mini/Files/Rail Components/PitClamp Mini - Rail - Standard Mod - Pivot V1.1.stl",
|
||||
"orig_site_json": "website/src/data/components/mounting/armpitmfg.json",
|
||||
"orig_item_id": "pitclamp-mini-rail-pivot",
|
||||
"status": "pending",
|
||||
"pinned_sha": null
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-ring-57aim",
|
||||
"source_repo": "armpitMFG/PitClamp-Mini",
|
||||
"source_path": "Files/Rings/PitClamp Mini - Ring v1.1 - 57AIM - 5mm Offset +Passthru.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/armpitMFG-PitClamp-Mini/Files/Rings/PitClamp Mini - Ring v1.1 - 57AIM - 5mm Offset +Passthru.stl",
|
||||
"orig_site_json": "website/src/data/components/mounting/armpitmfg.json",
|
||||
"orig_item_id": "pitclamp-mini-ring-57aim",
|
||||
"status": "pending",
|
||||
"pinned_sha": null
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-ring-ihsv57",
|
||||
"source_repo": "armpitMFG/PitClamp-Mini",
|
||||
"source_path": "Files/Rings/PitClamp Mini - Ring - iHSV57 - Default - 5mm Offset.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/armpitMFG-PitClamp-Mini/Files/Rings/PitClamp Mini - Ring - iHSV57 - Default - 5mm Offset.stl",
|
||||
"orig_site_json": "website/src/data/components/mounting/armpitmfg.json",
|
||||
"orig_item_id": "pitclamp-mini-ring-ihsv57",
|
||||
"status": "pending",
|
||||
"pinned_sha": null
|
||||
},
|
||||
{
|
||||
"id": "pivot-plate",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "pivot-plate",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"checksum_sha256": "44a5527b613743acc394e4a6dfe89677d37c2e1c8a1537e76184812edbba0216",
|
||||
"last_checked": "2026-01-07T09:38:41.826563+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
},
|
||||
{
|
||||
"id": "pivot-plate-right",
|
||||
"source_repo": "KinkyMakers/OSSM-hardware",
|
||||
"source_path": "Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"source_ref": "main",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"orig_site_json": "website/src/data/components/stand.json",
|
||||
"orig_item_id": "pivot-plate-right",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"checksum_sha256": "20fba186fd8c50f08b35d57c354e62288d582ad283aa474ee271b27f08aa972a",
|
||||
"last_checked": "2026-01-07T10:37:58.707054+00:00",
|
||||
"status": "up-to-date",
|
||||
"license": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/LICENCE",
|
||||
"upstream_latest_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab"
|
||||
}
|
||||
]
|
||||
75
roadmap/ROADMAP.md
Normal file
75
roadmap/ROADMAP.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# OSSM Configurator Roadmap
|
||||
|
||||
This document outlines the planned features, improvements, and milestones for the OSSM Configurator project.
|
||||
|
||||
## 🚀 Phase 1: Core Configuration & Data Integrity (Current Focus)
|
||||
The primary goal is to ensure the configurator accurately represents all available OSSM components and generates a perfect Bill of Materials.
|
||||
|
||||
* **Actuator Components**: Complete the mapping for all actuator body variations (Standard, AIM, iHSV, etc.).
|
||||
* **Stand & Mounting**: Finalize the hardware requirements for all stand types and mounting solutions.
|
||||
* **BOM Logic**: Refine the dependency logic (e.g., "replaces" field) to ensure no duplicate or missing parts.
|
||||
* **Price Accuracy**: Implement precise pricing for all hardware components.
|
||||
* **Import/Export**: Finalize the BOM export/import cycle for saving and resuming builds.
|
||||
|
||||
## 🎨 Phase 2: User Experience & Aesthetics
|
||||
Moving beyond functionality to create a premium, intuitive configuration experience.
|
||||
|
||||
* [x] **Dark Mode**: High-quality dark mode support across all components.
|
||||
* **Micro-animations**: Smooth transitions between wizard steps and hover effects on components.
|
||||
* **Loading States**: Add skeleton loaders and progress indicators for the Zip export process.
|
||||
* **Mobile Optimization**: Ensure the configurator is fully responsive for mobile builders.
|
||||
|
||||
## 🧊 Phase 3: Advanced Visualization (The Next Frontier)
|
||||
Introducing 3D visualization to help users see their build before they print.
|
||||
|
||||
* **Interactive 3D Preview**: Implement a real-time 3D renderer using React Three Fiber.
|
||||
* **Dynamic Customization**: Update the 3D model in real-time based on selected Primary and Accent colors.
|
||||
* **Compatibility Highlighting**: Visually show how components fit together in the 3D space.
|
||||
* **Exploded View**: Create an interactive exploded view component to help with assembly visualization.
|
||||
|
||||
## 🌐 Phase 4: Localization & Exporting
|
||||
Making the tool accessible to the global OSSM community.
|
||||
|
||||
* **Multi-language Support**: Translate the interface into major languages (German, Spanish, French, etc.).
|
||||
* **Multi-currency**: Dynamic currency conversion for the BOM summary.
|
||||
* **Imperial/Metric Toggle**: Support both systems for hardware and measurements.
|
||||
|
||||
## 🛠️ Phase 5: Build Guides & Integration
|
||||
Becoming the central hub for starting an OSSM build.
|
||||
|
||||
* **Integrated Assembly Guides**: Show assembly steps/READMEs directly within the configurator.
|
||||
* **FAQ & Troubleshooting**: A built-in guide for common build issues.
|
||||
* **Community Preset Gallery**: Allow users to share their configurations as presets for others to use.
|
||||
|
||||
---
|
||||
|
||||
## 📋 TODO List
|
||||
|
||||
### High Priority
|
||||
- [X] Finalize Actuator Components and mapping to BOM [Printed Parts Validated - Unknown location of M3x20mm SHCS]
|
||||
- [ ] Finalize Stand Components and mapping to BOM
|
||||
- [ ] Finalize PCB Components and mapping to BOM
|
||||
- [ ] Finalize Toy Mounts Components and mapping to BOM
|
||||
- [ ] Finalize Remote Control Components and mapping to BOM
|
||||
- [ ] Finalize Mounting Components and mapping to BOM
|
||||
- [ ] Finalize Other Components and mapping to BOM
|
||||
- [ ] Finalize Colors and mapping to BOM
|
||||
- [ ] Finalize Pricing and mapping to BOM
|
||||
- [ ] Finalize BOM Export and mapping to BOM
|
||||
- [ ] Finalize BOM Import and mapping to BOM
|
||||
|
||||
### Features & Infrastructure
|
||||
- [ ] Finalize Storage and sharing of BOMs
|
||||
- [ ] Add references to original hardware files and designs
|
||||
- [ ] Add Readme/assembly instructions for each component
|
||||
- [ ] Add FAQ and troubleshooting guide
|
||||
- [ ] Add support for multiple languages
|
||||
- [ ] Add support for multiple currencies
|
||||
- [ ] Add 3D render of final product with all components and options selected and colored [Planned - Phase 3]
|
||||
|
||||
### Future Considerations
|
||||
- [ ] Add support for multiple payment methods
|
||||
- [ ] Add support for multiple shipping methods
|
||||
- [ ] Add support for multiple shipping countries
|
||||
- [ ] Add support for multiple shipping regions
|
||||
- [ ] Add support for multiple shipping cities
|
||||
BIN
scripts/__pycache__/check_updates.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/check_updates.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/generate_manifest_from_site.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/generate_manifest_from_site.cpython-312.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/vendor_update.cpython-311.pyc
Normal file
BIN
scripts/__pycache__/vendor_update.cpython-311.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/vendor_update.cpython-312.pyc
Normal file
BIN
scripts/__pycache__/vendor_update.cpython-312.pyc
Normal file
Binary file not shown.
268
scripts/check_updates.py
Executable file
268
scripts/check_updates.py
Executable file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check for upstream updates to vendored files.
|
||||
|
||||
Queries GitHub API to detect if upstream files have changed since
|
||||
they were pinned. Produces a report of up-to-date and out-of-date entries.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class GitHubAPI:
|
||||
"""Simple GitHub API client for checking updates."""
|
||||
|
||||
def __init__(self, token: Optional[str] = None, delay: float = 0.5):
|
||||
self.token = token or os.getenv('GITHUB_API_TOKEN') or os.getenv('GITHUB_TOKEN')
|
||||
self.session = requests.Session()
|
||||
if self.token:
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
})
|
||||
self.base_url = 'https://api.github.com'
|
||||
self.delay = delay # Delay between requests in seconds
|
||||
self.last_request_time = 0
|
||||
|
||||
def _wait_for_rate_limit(self, response: requests.Response) -> None:
|
||||
"""Wait if rate limited, using reset time from headers."""
|
||||
if response.status_code == 403:
|
||||
# Check if it's a rate limit error
|
||||
rate_limit_remaining = response.headers.get('X-RateLimit-Remaining', '1')
|
||||
if rate_limit_remaining == '0' or 'rate limit' in response.text.lower():
|
||||
reset_time = response.headers.get('X-RateLimit-Reset')
|
||||
if reset_time:
|
||||
reset_timestamp = int(reset_time)
|
||||
wait_seconds = max(0, reset_timestamp - int(time.time())) + 1
|
||||
print(f" Rate limit exceeded. Waiting {wait_seconds} seconds until reset...", file=sys.stderr)
|
||||
time.sleep(wait_seconds)
|
||||
else:
|
||||
# Fallback: wait 60 seconds
|
||||
print(" Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
|
||||
time.sleep(60)
|
||||
|
||||
def _rate_limit_delay(self) -> None:
|
||||
"""Add delay between requests to avoid hitting rate limits."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
if time_since_last < self.delay:
|
||||
time.sleep(self.delay - time_since_last)
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def _make_request(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
||||
"""Make a request with rate limit handling and retries."""
|
||||
for attempt in range(max_retries):
|
||||
self._rate_limit_delay()
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
|
||||
# Check rate limit
|
||||
if response.status_code == 403:
|
||||
self._wait_for_rate_limit(response)
|
||||
# Retry the request after waiting
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
|
||||
# Check remaining rate limit
|
||||
remaining = response.headers.get('X-RateLimit-Remaining')
|
||||
if remaining:
|
||||
remaining_int = int(remaining)
|
||||
if remaining_int < 10:
|
||||
print(f" Warning: Only {remaining_int} API requests remaining. Adding delay...", file=sys.stderr)
|
||||
time.sleep(2)
|
||||
|
||||
return response
|
||||
|
||||
except requests.RequestException as e:
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2 ** attempt # Exponential backoff
|
||||
print(f" Request failed, retrying in {wait_time}s... ({e})", file=sys.stderr)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise
|
||||
|
||||
return response
|
||||
|
||||
def get_latest_commit_sha(self, owner: str, repo: str, path: str, ref: str) -> Optional[str]:
|
||||
"""
|
||||
Get the latest commit SHA that modified a file at the given ref.
|
||||
"""
|
||||
commits_url = f"{self.base_url}/repos/{owner}/{repo}/commits"
|
||||
params = {
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
}
|
||||
|
||||
try:
|
||||
response = self._make_request('GET', commits_url, params=params)
|
||||
response.raise_for_status()
|
||||
commits = response.json()
|
||||
|
||||
if commits:
|
||||
return commits[0]['sha']
|
||||
|
||||
# If no commits found, try to resolve the ref to a SHA
|
||||
# Check if ref is already a SHA
|
||||
if len(ref) == 40 and all(c in '0123456789abcdef' for c in ref.lower()):
|
||||
return ref
|
||||
|
||||
# Try to resolve branch/tag to SHA
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/heads/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
# Try tag
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/tags/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"Error checking updates for {owner}/{repo}/{path}@{ref}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def check_entry(entry: Dict, api: GitHubAPI) -> Dict:
|
||||
"""Check a single manifest entry for updates."""
|
||||
source_repo = entry['source_repo']
|
||||
owner, repo = source_repo.split('/', 1)
|
||||
source_path = entry['source_path']
|
||||
source_ref = entry.get('source_ref', 'main')
|
||||
pinned_sha = entry.get('pinned_sha')
|
||||
|
||||
# Get latest commit SHA
|
||||
latest_sha = api.get_latest_commit_sha(owner, repo, source_path, source_ref)
|
||||
|
||||
if not latest_sha:
|
||||
entry['status'] = 'unknown'
|
||||
entry['upstream_latest_sha'] = None
|
||||
return entry
|
||||
|
||||
# Update upstream_latest_sha
|
||||
entry['upstream_latest_sha'] = latest_sha
|
||||
entry['last_checked'] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Compare with pinned SHA
|
||||
if not pinned_sha:
|
||||
entry['status'] = 'unknown'
|
||||
elif latest_sha == pinned_sha:
|
||||
entry['status'] = 'up-to-date'
|
||||
else:
|
||||
entry['status'] = 'out-of-date'
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Check for upstream updates to vendored files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--manifest',
|
||||
type=Path,
|
||||
default=Path('manifest/vendor_manifest.json'),
|
||||
help='Path to manifest file (default: manifest/vendor_manifest.json)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
type=Path,
|
||||
help='Path to write report JSON (optional)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--delay',
|
||||
type=float,
|
||||
default=0.5,
|
||||
help='Delay between API requests in seconds (default: 0.5)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve paths
|
||||
script_dir = Path(__file__).parent.parent
|
||||
manifest_path = (script_dir / args.manifest).resolve()
|
||||
|
||||
if not manifest_path.exists():
|
||||
print(f"Error: Manifest file not found: {manifest_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load manifest
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest_data = json.load(f)
|
||||
|
||||
# Convert to list if it's a dict
|
||||
if isinstance(manifest_data, dict):
|
||||
manifest_list = list(manifest_data.values())
|
||||
else:
|
||||
manifest_list = manifest_data
|
||||
|
||||
# Initialize GitHub API with delay
|
||||
api = GitHubAPI(delay=args.delay)
|
||||
|
||||
# Check each entry
|
||||
print("Checking for upstream updates...")
|
||||
updated_entries = []
|
||||
out_of_date_count = 0
|
||||
|
||||
for entry in manifest_list:
|
||||
updated_entry = check_entry(entry, api)
|
||||
updated_entries.append(updated_entry)
|
||||
|
||||
if updated_entry['status'] == 'out-of-date':
|
||||
out_of_date_count += 1
|
||||
print(f" ⚠️ {updated_entry['id']}: OUT-OF-DATE")
|
||||
print(f" Pinned: {updated_entry.get('pinned_sha', 'N/A')[:8]}...")
|
||||
print(f" Latest: {updated_entry.get('upstream_latest_sha', 'N/A')[:8]}...")
|
||||
elif updated_entry['status'] == 'up-to-date':
|
||||
print(f" ✓ {updated_entry['id']}: up-to-date")
|
||||
else:
|
||||
print(f" ? {updated_entry['id']}: {updated_entry['status']}")
|
||||
|
||||
# Create report
|
||||
report = {
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'total_entries': len(updated_entries),
|
||||
'up_to_date': sum(1 for e in updated_entries if e['status'] == 'up-to-date'),
|
||||
'out_of_date': out_of_date_count,
|
||||
'unknown': sum(1 for e in updated_entries if e['status'] == 'unknown'),
|
||||
'entries': updated_entries
|
||||
}
|
||||
|
||||
# Write report if requested
|
||||
if args.output:
|
||||
output_path = (script_dir / args.output).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, indent=2, sort_keys=False)
|
||||
print(f"\nReport written to {output_path}")
|
||||
|
||||
# Print summary
|
||||
print(f"\nSummary:")
|
||||
print(f" Total entries: {report['total_entries']}")
|
||||
print(f" Up-to-date: {report['up_to_date']}")
|
||||
print(f" Out-of-date: {report['out_of_date']}")
|
||||
print(f" Unknown: {report['unknown']}")
|
||||
|
||||
# Exit with non-zero code if any entries are out-of-date
|
||||
if out_of_date_count > 0:
|
||||
print(f"\n⚠️ {out_of_date_count} entries need updates!", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✓ All entries are up-to-date.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
6
scripts/requirements.txt
Normal file
6
scripts/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
requests>=2.31.0
|
||||
PyGithub>=2.1.0
|
||||
pytest>=7.4.0
|
||||
pytest-mock>=3.11.1
|
||||
responses>=0.23.1
|
||||
flask>=3.0.0
|
||||
672
scripts/vendor_update.py
Executable file
672
scripts/vendor_update.py
Executable file
@@ -0,0 +1,672 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Download and pin external asset files from GitHub.
|
||||
|
||||
Automatically scans website/src/data/components for parts with GitHub URLs,
|
||||
updates the manifest, and then downloads/pins files.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Generator, Any
|
||||
from urllib.parse import urlparse, unquote, parse_qs
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class GitHubAPI:
|
||||
"""Simple GitHub API client with rate limit handling."""
|
||||
|
||||
def __init__(self, token: Optional[str] = None, delay: float = 0.5):
|
||||
self.token = token or os.getenv('GITHUB_API_TOKEN') or os.getenv('GITHUB_TOKEN')
|
||||
self.session = requests.Session()
|
||||
if self.token:
|
||||
self.session.headers.update({
|
||||
'Authorization': f'token {self.token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
})
|
||||
self.base_url = 'https://api.github.com'
|
||||
self.delay = delay # Delay between requests in seconds
|
||||
self.last_request_time = 0
|
||||
|
||||
def _wait_for_rate_limit(self, response: requests.Response) -> None:
|
||||
"""Wait if rate limited, using reset time from headers."""
|
||||
if response.status_code == 403:
|
||||
# Check if it's a rate limit error
|
||||
rate_limit_remaining = response.headers.get('X-RateLimit-Remaining', '1')
|
||||
if rate_limit_remaining == '0' or 'rate limit' in response.text.lower():
|
||||
reset_time = response.headers.get('X-RateLimit-Reset')
|
||||
if reset_time:
|
||||
reset_timestamp = int(reset_time)
|
||||
wait_seconds = max(0, reset_timestamp - int(time.time())) + 1
|
||||
print(f" Rate limit exceeded. Waiting {wait_seconds} seconds until reset...", file=sys.stderr)
|
||||
time.sleep(wait_seconds)
|
||||
else:
|
||||
# Fallback: wait 60 seconds
|
||||
print(" Rate limit exceeded. Waiting 60 seconds...", file=sys.stderr)
|
||||
time.sleep(60)
|
||||
|
||||
def _rate_limit_delay(self) -> None:
|
||||
"""Add delay between requests to avoid hitting rate limits."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
if time_since_last < self.delay:
|
||||
time.sleep(self.delay - time_since_last)
|
||||
self.last_request_time = time.time()
|
||||
|
||||
def _make_request(self, method: str, url: str, max_retries: int = 3, **kwargs) -> requests.Response:
|
||||
"""Make a request with rate limit handling and retries."""
|
||||
for attempt in range(max_retries):
|
||||
self._rate_limit_delay()
|
||||
|
||||
try:
|
||||
response = self.session.request(method, url, **kwargs)
|
||||
|
||||
# Check rate limit
|
||||
if response.status_code == 403:
|
||||
self._wait_for_rate_limit(response)
|
||||
# Retry the request after waiting
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
|
||||
# Check remaining rate limit
|
||||
remaining = response.headers.get('X-RateLimit-Remaining')
|
||||
if remaining:
|
||||
remaining_int = int(remaining)
|
||||
if remaining_int < 10:
|
||||
print(f" Warning: Only {remaining_int} API requests remaining. Adding delay...", file=sys.stderr)
|
||||
time.sleep(2)
|
||||
|
||||
return response
|
||||
|
||||
except requests.RequestException as e:
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2 ** attempt # Exponential backoff
|
||||
print(f" Request failed, retrying in {wait_time}s... ({e})", file=sys.stderr)
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
raise
|
||||
|
||||
return response
|
||||
|
||||
def get_default_branch(self, owner: str, repo: str) -> str:
|
||||
"""Get default branch for a repository."""
|
||||
url = f"{self.base_url}/repos/{owner}/{repo}"
|
||||
try:
|
||||
response = self._make_request('GET', url)
|
||||
response.raise_for_status()
|
||||
return response.json().get('default_branch', 'main')
|
||||
except requests.RequestException as e:
|
||||
print(f"Warning: Could not get default branch for {owner}/{repo}: {e}", file=sys.stderr)
|
||||
return 'main'
|
||||
|
||||
def get_file_sha(self, owner: str, repo: str, path: str, ref: str) -> Optional[str]:
|
||||
"""
|
||||
Get the commit SHA that last modified a file at a given ref.
|
||||
Uses Contents API to get file info, then finds the commit.
|
||||
"""
|
||||
# First, try to get file contents to verify it exists
|
||||
url = f"{self.base_url}/repos/{owner}/{repo}/contents/{path}"
|
||||
params = {'ref': ref}
|
||||
|
||||
try:
|
||||
response = self._make_request('GET', url, params=params)
|
||||
if response.status_code == 404:
|
||||
# File doesn't exist at this ref, try default branch
|
||||
default_branch = self.get_default_branch(owner, repo)
|
||||
if default_branch != ref:
|
||||
params['ref'] = default_branch
|
||||
response = self._make_request('GET', url, params=params)
|
||||
|
||||
response.raise_for_status()
|
||||
file_info = response.json()
|
||||
|
||||
# Get the commit SHA from the file info
|
||||
# The Contents API returns 'sha' which is the blob SHA, not commit SHA
|
||||
# We need to find the commit that last modified this file
|
||||
commits_url = f"{self.base_url}/repos/{owner}/{repo}/commits"
|
||||
commits_params = {
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
}
|
||||
|
||||
commits_response = self._make_request('GET', commits_url, params=commits_params)
|
||||
commits_response.raise_for_status()
|
||||
commits = commits_response.json()
|
||||
|
||||
if commits:
|
||||
return commits[0]['sha']
|
||||
|
||||
# Fallback: use the ref as-is if it's already a SHA
|
||||
if len(ref) == 40 and all(c in '0123456789abcdef' for c in ref.lower()):
|
||||
return ref
|
||||
|
||||
# Last resort: resolve ref to SHA
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/heads/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
# If ref is a tag
|
||||
ref_url = f"{self.base_url}/repos/{owner}/{repo}/git/ref/tags/{ref}"
|
||||
ref_response = self._make_request('GET', ref_url)
|
||||
if ref_response.status_code == 200:
|
||||
return ref_response.json()['object']['sha']
|
||||
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"Error getting file SHA for {owner}/{repo}/{path}@{ref}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def get_license(self, owner: str, repo: str, sha: str) -> Optional[str]:
|
||||
"""Try to detect license from repository root at given SHA."""
|
||||
license_files = ['LICENSE', 'LICENSE.txt', 'LICENSE.md', 'LICENCE', 'LICENCE.txt']
|
||||
|
||||
for license_file in license_files:
|
||||
url = f"{self.base_url}/repos/{owner}/{repo}/contents/{license_file}"
|
||||
params = {'ref': sha}
|
||||
|
||||
try:
|
||||
response = self._make_request('GET', url, params=params)
|
||||
if response.status_code == 200:
|
||||
# Found a license file, return URL to it
|
||||
return f"https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{license_file}"
|
||||
except requests.RequestException:
|
||||
continue
|
||||
|
||||
# Try to get license from repository info
|
||||
try:
|
||||
repo_url = f"{self.base_url}/repos/{owner}/{repo}"
|
||||
response = self._make_request('GET', repo_url)
|
||||
response.raise_for_status()
|
||||
repo_info = response.json()
|
||||
license_info = repo_info.get('license')
|
||||
if license_info:
|
||||
return license_info.get('spdx_id') or license_info.get('url')
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def compute_sha256(file_path: Path) -> str:
|
||||
"""Compute SHA256 checksum of a file."""
|
||||
sha256 = hashlib.sha256()
|
||||
with open(file_path, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b''):
|
||||
sha256.update(chunk)
|
||||
return sha256.hexdigest()
|
||||
|
||||
|
||||
def download_file(url: str, dest_path: Path) -> bool:
|
||||
"""Download a file from URL to destination path."""
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
# Create parent directories
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download file
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
print(f"Error downloading {url}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def parse_github_url(url: str) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Parse GitHub URL to return (owner, repo, ref, path).
|
||||
Supports:
|
||||
- https://github.com/owner/repo/blob/<ref>/path/to/file
|
||||
- https://github.com/owner/repo/raw/<ref>/path/to/file
|
||||
- https://raw.githubusercontent.com/owner/repo/<ref>/path/to/file
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return None, None, None, None
|
||||
|
||||
# Check if it's a GitHub URL
|
||||
if 'github.com' not in url:
|
||||
return None, None, None, None
|
||||
|
||||
try:
|
||||
# Handle raw.githubusercontent.com
|
||||
if 'raw.githubusercontent.com' in url:
|
||||
match_parts = url.split('/')
|
||||
# https://raw.githubusercontent.com/OWNER/REPO/REF/PATH...
|
||||
# parts: [https:, , raw.githubusercontent.com, OWNER, REPO, REF, PATH...]
|
||||
if len(match_parts) >= 6:
|
||||
owner = match_parts[3]
|
||||
repo = match_parts[4]
|
||||
ref = match_parts[5]
|
||||
path = '/'.join(match_parts[6:]).split('?')[0]
|
||||
return owner, repo, ref, unquote(path)
|
||||
|
||||
# Handle github.com and action.github.com
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path.strip('/')
|
||||
path_parts = path.split('/')
|
||||
|
||||
if len(path_parts) >= 4:
|
||||
owner = path_parts[0]
|
||||
repo = path_parts[1]
|
||||
mode = path_parts[2] # 'blob' or 'raw'
|
||||
|
||||
if mode in ('blob', 'raw'):
|
||||
ref = path_parts[3]
|
||||
file_path = '/'.join(path_parts[4:])
|
||||
|
||||
# Check query params for ?raw=true
|
||||
query_params = parse_qs(parsed.query)
|
||||
if 'raw' in query_params or mode == 'raw':
|
||||
return owner, repo, ref, unquote(file_path)
|
||||
|
||||
# Also treat 'blob' as a valid source if we just want the path
|
||||
return owner, repo, ref, unquote(file_path)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None, None, None, None
|
||||
|
||||
|
||||
def scan_site_components(components_dir: Path) -> Generator[Dict[str, Any], None, None]:
|
||||
"""Recursively scan JSON files for parts with GitHub URLs."""
|
||||
for json_file in components_dir.rglob('*.json'):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Helper to find parts
|
||||
queue = [data]
|
||||
while queue:
|
||||
item = queue.pop(0)
|
||||
if isinstance(item, dict):
|
||||
# Check if this item is a part
|
||||
if 'id' in item and 'url' in item and item['url']:
|
||||
owner, repo, ref, source_path = parse_github_url(item['url'])
|
||||
if owner and repo and source_path:
|
||||
yield {
|
||||
'id': item['id'],
|
||||
'url': item['url'],
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'ref': ref or 'main',
|
||||
'source_path': source_path,
|
||||
'orig_site_json': json_file
|
||||
}
|
||||
|
||||
# Add children to queue
|
||||
queue.extend(item.values())
|
||||
elif isinstance(item, list):
|
||||
queue.extend(item)
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"Warning: Could not read {json_file}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def regenerate_manifest(manifest_path: Path, repo_root: Path) -> Tuple[List[Dict], int]:
|
||||
"""
|
||||
Regenerate manifest from site data.
|
||||
Preserves state of existing entries.
|
||||
Returns (new_manifest_list, changes_count).
|
||||
"""
|
||||
print("Scanning website components to regenerate manifest...")
|
||||
|
||||
# Load existing manifest to preserve state
|
||||
old_manifest = {}
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
old_manifest = {entry['id']: entry for entry in data}
|
||||
|
||||
new_manifest = {}
|
||||
components_dir = repo_root / 'website/src/data/components'
|
||||
changes_count = 0
|
||||
|
||||
if not components_dir.exists():
|
||||
print(f"Warning: Components directory not found: {components_dir}", file=sys.stderr)
|
||||
return list(old_manifest.values()), 0
|
||||
|
||||
for part in scan_site_components(components_dir):
|
||||
part_id = part['id']
|
||||
old_entry = old_manifest.get(part_id)
|
||||
|
||||
# Calculate local path
|
||||
# vendor/{owner}-{repo}/{path}
|
||||
local_path = f"vendor/{part['owner']}-{part['repo']}/{part['source_path']}"
|
||||
source_repo = f"{part['owner']}/{part['repo']}"
|
||||
orig_site_json = str(part['orig_site_json'].relative_to(repo_root))
|
||||
|
||||
entry = {
|
||||
'id': part_id,
|
||||
'source_repo': source_repo,
|
||||
'source_path': part['source_path'],
|
||||
'source_ref': part['ref'],
|
||||
'local_path': local_path,
|
||||
'orig_site_json': orig_site_json,
|
||||
'orig_item_id': part_id
|
||||
}
|
||||
|
||||
# Preserve state if exists and config matches
|
||||
if old_entry:
|
||||
# Check if source config changed
|
||||
config_changed = (
|
||||
old_entry.get('source_repo') != source_repo or
|
||||
old_entry.get('source_path') != part['source_path'] or
|
||||
old_entry.get('source_ref') != part['ref']
|
||||
)
|
||||
|
||||
if not config_changed:
|
||||
# Copy state
|
||||
for key in ['pinned_sha', 'pinned_raw_url', 'checksum_sha256', 'last_checked', 'status', 'license', 'upstream_latest_sha']:
|
||||
if key in old_entry:
|
||||
entry[key] = old_entry[key]
|
||||
else:
|
||||
print(f" Config changed for {part_id}, resetting status.")
|
||||
entry['status'] = 'pending'
|
||||
entry['pinned_sha'] = None
|
||||
changes_count += 1
|
||||
|
||||
# Check if we updated manifest info (like orig_site_json moved)
|
||||
if (old_entry.get('orig_site_json') != orig_site_json or
|
||||
old_entry.get('local_path') != local_path):
|
||||
changes_count += 1
|
||||
else:
|
||||
print(f" New part found: {part_id}")
|
||||
entry['status'] = 'pending'
|
||||
entry['pinned_sha'] = None
|
||||
changes_count += 1
|
||||
|
||||
new_manifest[part_id] = entry
|
||||
|
||||
# Check for removed items
|
||||
removed_count = len(old_manifest) - len(new_manifest)
|
||||
if removed_count > 0:
|
||||
print(f" Removed {removed_count} parts that are no longer in site JSONs.")
|
||||
changes_count += removed_count
|
||||
|
||||
return sorted(new_manifest.values(), key=lambda x: x['id']), changes_count
|
||||
|
||||
|
||||
def update_manifest_entry(
|
||||
entry: Dict,
|
||||
api: GitHubAPI,
|
||||
repo_root: Path,
|
||||
dry_run: bool = False
|
||||
) -> Dict:
|
||||
"""Update a single manifest entry by downloading and pinning the file."""
|
||||
source_repo = entry['source_repo']
|
||||
owner, repo = source_repo.split('/', 1)
|
||||
source_path = entry['source_path']
|
||||
source_ref = entry.get('source_ref', 'main')
|
||||
|
||||
print(f"Processing {entry['id']} from {source_repo}/{source_path}@{source_ref}...")
|
||||
|
||||
# Get commit SHA for the file
|
||||
commit_sha = api.get_file_sha(owner, repo, source_path, source_ref)
|
||||
if not commit_sha:
|
||||
print(f" Warning: Could not resolve SHA for {source_ref}, skipping", file=sys.stderr)
|
||||
entry['status'] = 'error'
|
||||
return entry
|
||||
|
||||
# Build pinned raw URL
|
||||
pinned_raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{commit_sha}/{source_path}"
|
||||
|
||||
# Determine local path
|
||||
local_path = Path(entry['local_path'])
|
||||
if not local_path.is_absolute():
|
||||
local_path = repo_root / local_path
|
||||
|
||||
# Check if file exists and is already at the correct version
|
||||
current_pinned_sha = entry.get('pinned_sha')
|
||||
if current_pinned_sha == commit_sha and local_path.exists():
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] File up to date ({commit_sha}), would skip download.")
|
||||
else:
|
||||
print(f" File up to date ({commit_sha}), skipping download.")
|
||||
# Ensure checksum is present
|
||||
if 'checksum_sha256' not in entry or not entry['checksum_sha256']:
|
||||
entry['checksum_sha256'] = compute_sha256(local_path)
|
||||
|
||||
entry['pinned_sha'] = commit_sha
|
||||
entry['pinned_raw_url'] = pinned_raw_url
|
||||
entry['last_checked'] = datetime.now(timezone.utc).isoformat()
|
||||
entry['upstream_latest_sha'] = commit_sha
|
||||
entry['status'] = 'up-to-date'
|
||||
|
||||
# If license is missing, try to get it, otherwise keep existing
|
||||
if 'license' not in entry and not dry_run:
|
||||
license_info = api.get_license(owner, repo, commit_sha)
|
||||
if license_info:
|
||||
entry['license'] = license_info
|
||||
|
||||
return entry
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would download to {local_path}")
|
||||
print(f" [DRY RUN] Pinned SHA: {commit_sha}")
|
||||
entry['pinned_sha'] = commit_sha
|
||||
entry['pinned_raw_url'] = pinned_raw_url
|
||||
entry['last_checked'] = datetime.now(timezone.utc).isoformat()
|
||||
entry['upstream_latest_sha'] = commit_sha
|
||||
entry['status'] = 'up-to-date'
|
||||
return entry
|
||||
|
||||
# Download file
|
||||
print(f" Downloading from {pinned_raw_url}...")
|
||||
if not download_file(pinned_raw_url, local_path):
|
||||
entry['status'] = 'error'
|
||||
return entry
|
||||
|
||||
# Compute checksum
|
||||
checksum = compute_sha256(local_path)
|
||||
print(f" Checksum: {checksum[:16]}...")
|
||||
|
||||
# Get license info
|
||||
license_info = api.get_license(owner, repo, commit_sha)
|
||||
|
||||
# Update entry
|
||||
entry['pinned_sha'] = commit_sha
|
||||
entry['pinned_raw_url'] = pinned_raw_url
|
||||
entry['checksum_sha256'] = checksum
|
||||
entry['last_checked'] = datetime.now(timezone.utc).isoformat()
|
||||
entry['upstream_latest_sha'] = commit_sha
|
||||
entry['status'] = 'up-to-date'
|
||||
if license_info:
|
||||
entry['license'] = license_info
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def sync_to_site_json(entry: Dict, repo_root: Path) -> bool:
|
||||
"""Sync vendor metadata back to the original site JSON file."""
|
||||
orig_json_path = entry.get('orig_site_json')
|
||||
orig_item_id = entry.get('orig_item_id')
|
||||
|
||||
if not orig_json_path or not orig_item_id:
|
||||
return False
|
||||
|
||||
json_path = repo_root / orig_json_path
|
||||
if not json_path.exists():
|
||||
print(f" Warning: Site JSON file not found: {json_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Find the printed part in the nested structure
|
||||
def find_and_update_part(obj, target_id):
|
||||
if isinstance(obj, dict):
|
||||
# If this object IS the part (has the ID)
|
||||
if obj.get('id') == target_id:
|
||||
if 'vendor' not in obj:
|
||||
obj['vendor'] = {}
|
||||
obj['vendor'].update({
|
||||
'manifest_id': entry['id'],
|
||||
'local_path': entry['local_path'],
|
||||
'pinned_sha': entry['pinned_sha'],
|
||||
'pinned_raw_url': entry['pinned_raw_url'],
|
||||
'checksum_sha256': entry['checksum_sha256'],
|
||||
'last_checked': entry['last_checked'],
|
||||
'status': entry['status']
|
||||
})
|
||||
return True
|
||||
|
||||
# Recursively search values
|
||||
for value in obj.values():
|
||||
if find_and_update_part(value, target_id):
|
||||
return True
|
||||
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
if find_and_update_part(item, target_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
if not find_and_update_part(data, orig_item_id):
|
||||
print(f" Warning: Could not find part with id '{orig_item_id}' in {json_path}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Write back to file (preserve formatting)
|
||||
with open(json_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f" Updated {json_path}")
|
||||
return True
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f" Error updating {json_path}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Download and pin external asset files from GitHub'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--manifest',
|
||||
type=Path,
|
||||
default=Path('manifest/vendor_manifest.json'),
|
||||
help='Path to manifest file (default: manifest/vendor_manifest.json)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--entry',
|
||||
type=str,
|
||||
help='Process only a specific manifest entry by ID'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be done without downloading files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-sync',
|
||||
action='store_true',
|
||||
help='Skip syncing vendor metadata back to site JSON files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--delay',
|
||||
type=float,
|
||||
default=0.5,
|
||||
help='Delay between API requests in seconds (default: 0.5)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-scan',
|
||||
action='store_true',
|
||||
help='Skip scanning website for new components'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--scan-only',
|
||||
action='store_true',
|
||||
help='Only scan website and update manifest, do not check/download files'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve paths
|
||||
script_dir = Path(__file__).parent.parent
|
||||
manifest_path = (script_dir / args.manifest).resolve()
|
||||
repo_root = script_dir
|
||||
|
||||
# Regenerate manifest from website scan (unless disabled)
|
||||
if not args.no_scan and not args.entry:
|
||||
manifest_list, changes = regenerate_manifest(manifest_path, repo_root)
|
||||
if changes > 0:
|
||||
print(f"Manifest regenerated with {changes} changes.")
|
||||
if not args.dry_run:
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest_list, f, indent=2, sort_keys=False)
|
||||
else:
|
||||
print("No changes in manifest structure detected.")
|
||||
|
||||
if args.scan_only:
|
||||
return
|
||||
|
||||
# Reload manifest data for processing
|
||||
manifest_data = manifest_list
|
||||
else:
|
||||
if not manifest_path.exists():
|
||||
print(f"Error: Manifest file not found: {manifest_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest_data = json.load(f)
|
||||
|
||||
# Convert to dict if it's a list
|
||||
if isinstance(manifest_data, list):
|
||||
manifest = {entry['id']: entry for entry in manifest_data}
|
||||
else:
|
||||
manifest = manifest_data
|
||||
|
||||
# Filter entries if --entry specified
|
||||
if args.entry:
|
||||
if args.entry not in manifest:
|
||||
print(f"Error: Entry '{args.entry}' not found in manifest", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
entries_to_process = {args.entry: manifest[args.entry]}
|
||||
else:
|
||||
entries_to_process = manifest
|
||||
|
||||
# Initialize GitHub API with delay
|
||||
api = GitHubAPI(delay=args.delay)
|
||||
|
||||
# Process entries
|
||||
updated_count = 0
|
||||
for entry_id, entry in entries_to_process.items():
|
||||
updated_entry = update_manifest_entry(entry, api, repo_root, dry_run=args.dry_run)
|
||||
manifest[entry_id] = updated_entry
|
||||
|
||||
if not args.no_sync and not args.dry_run:
|
||||
sync_to_site_json(updated_entry, repo_root)
|
||||
|
||||
updated_count += 1
|
||||
|
||||
# Write updated manifest
|
||||
if not args.dry_run:
|
||||
manifest_list = sorted(manifest.values(), key=lambda x: x['id'])
|
||||
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(manifest_list, f, indent=2, sort_keys=False)
|
||||
print(f"\nUpdated manifest with {updated_count} entries.")
|
||||
else:
|
||||
print(f"\n[DRY RUN] Would update {updated_count} entries.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
240
tests/test_check_updates.py
Normal file
240
tests/test_check_updates.py
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for check_updates.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
# Import the module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts'))
|
||||
|
||||
from check_updates import GitHubAPI, check_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_api():
|
||||
"""Create a GitHubAPI instance for testing."""
|
||||
return GitHubAPI(token='test-token')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_manifest_entry():
|
||||
"""Sample manifest entry for testing."""
|
||||
return {
|
||||
'id': 'test-entry',
|
||||
'source_repo': 'owner/repo',
|
||||
'source_path': 'path/to/file.stl',
|
||||
'source_ref': 'main',
|
||||
'pinned_sha': 'pinned-sha-123',
|
||||
'pinned_raw_url': 'https://raw.githubusercontent.com/owner/repo/pinned-sha-123/path/to/file.stl',
|
||||
'local_path': 'vendor/owner-repo/path/to/file.stl',
|
||||
'checksum_sha256': 'abc123',
|
||||
'last_checked': '2024-01-01T00:00:00Z',
|
||||
'upstream_latest_sha': None,
|
||||
'status': 'unknown',
|
||||
'license': None
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_up_to_date(github_api, sample_manifest_entry):
|
||||
"""Test checking an entry that is up-to-date."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
pinned_sha = 'pinned-sha-123'
|
||||
|
||||
# Mock commits API - return same SHA as pinned
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': pinned_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'up-to-date'
|
||||
assert updated_entry['upstream_latest_sha'] == pinned_sha
|
||||
assert updated_entry['last_checked'] is not None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_out_of_date(github_api, sample_manifest_entry):
|
||||
"""Test checking an entry that is out-of-date."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
pinned_sha = 'pinned-sha-123'
|
||||
latest_sha = 'latest-sha-456'
|
||||
|
||||
# Mock commits API - return different SHA
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': latest_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'out-of-date'
|
||||
assert updated_entry['upstream_latest_sha'] == latest_sha
|
||||
assert updated_entry['pinned_sha'] == pinned_sha # Pinned SHA unchanged
|
||||
assert updated_entry['last_checked'] is not None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_no_pinned_sha(github_api):
|
||||
"""Test checking an entry with no pinned SHA."""
|
||||
entry = {
|
||||
'id': 'test-entry',
|
||||
'source_repo': 'owner/repo',
|
||||
'source_path': 'path/to/file.stl',
|
||||
'source_ref': 'main',
|
||||
'pinned_sha': None,
|
||||
'status': 'unknown'
|
||||
}
|
||||
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
latest_sha = 'latest-sha-456'
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': latest_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'unknown'
|
||||
assert updated_entry['upstream_latest_sha'] == latest_sha
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_api_error(github_api, sample_manifest_entry):
|
||||
"""Test handling of API errors."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock API error
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
status=500
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
assert updated_entry['status'] == 'unknown'
|
||||
assert updated_entry['upstream_latest_sha'] is None
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_check_entry_file_not_found(github_api, sample_manifest_entry):
|
||||
"""Test handling when file doesn't exist at ref."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock empty commits response (file doesn't exist)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
updated_entry = check_entry(sample_manifest_entry, github_api)
|
||||
|
||||
# Should still update last_checked but status might be unknown
|
||||
assert updated_entry['last_checked'] is not None
|
||||
|
||||
|
||||
def test_github_api_get_latest_commit_sha(github_api):
|
||||
"""Test getting latest commit SHA."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'file.stl'
|
||||
ref = 'main'
|
||||
expected_sha = 'commit-sha-789'
|
||||
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': expected_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
sha = github_api.get_latest_commit_sha(owner, repo, path, ref)
|
||||
|
||||
assert sha == expected_sha
|
||||
|
||||
|
||||
def test_github_api_get_latest_commit_sha_ref_is_sha(github_api):
|
||||
"""Test when ref is already a SHA."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'file.stl'
|
||||
ref = 'a' * 40 # Valid SHA format
|
||||
|
||||
# Should return the ref as-is if it's already a SHA
|
||||
sha = github_api.get_latest_commit_sha(owner, repo, path, ref)
|
||||
|
||||
# Actually, the function tries to get commits first, so it will make an API call
|
||||
# But if ref is a SHA, it should work
|
||||
with responses.RequestsMock() as rsps:
|
||||
rsps.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': ref}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
sha = github_api.get_latest_commit_sha(owner, repo, path, ref)
|
||||
assert sha == ref
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
317
tests/test_vendor_update.py
Normal file
317
tests/test_vendor_update.py
Normal file
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for vendor_update.py
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, mock_open
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
# Import the module (adjust path as needed)
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / 'scripts'))
|
||||
|
||||
from vendor_update import GitHubAPI, compute_sha256, download_file, update_manifest_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_manifest_entry():
|
||||
"""Sample manifest entry for testing."""
|
||||
return {
|
||||
'id': 'test-entry',
|
||||
'source_repo': 'owner/repo',
|
||||
'source_path': 'path/to/file.stl',
|
||||
'source_ref': 'main',
|
||||
'pinned_sha': None,
|
||||
'pinned_raw_url': None,
|
||||
'local_path': 'vendor/owner-repo/path/to/file.stl',
|
||||
'checksum_sha256': None,
|
||||
'last_checked': None,
|
||||
'upstream_latest_sha': None,
|
||||
'status': 'unknown',
|
||||
'license': None
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_api():
|
||||
"""Create a GitHubAPI instance for testing."""
|
||||
return GitHubAPI(token='test-token')
|
||||
|
||||
|
||||
def test_compute_sha256(temp_dir):
|
||||
"""Test SHA256 computation."""
|
||||
test_file = temp_dir / 'test.txt'
|
||||
test_file.write_text('test content')
|
||||
|
||||
checksum = compute_sha256(test_file)
|
||||
|
||||
# Verify it's a valid SHA256 hex string
|
||||
assert len(checksum) == 64
|
||||
assert all(c in '0123456789abcdef' for c in checksum.lower())
|
||||
|
||||
# Verify it matches expected hash
|
||||
expected = hashlib.sha256(b'test content').hexdigest()
|
||||
assert checksum == expected
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_download_file_success(temp_dir):
|
||||
"""Test successful file download."""
|
||||
test_url = 'https://example.com/file.stl'
|
||||
test_content = b'STL file content'
|
||||
dest_path = temp_dir / 'downloaded.stl'
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
test_url,
|
||||
body=test_content,
|
||||
status=200
|
||||
)
|
||||
|
||||
result = download_file(test_url, dest_path)
|
||||
|
||||
assert result is True
|
||||
assert dest_path.exists()
|
||||
assert dest_path.read_bytes() == test_content
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_download_file_failure():
|
||||
"""Test file download failure."""
|
||||
test_url = 'https://example.com/missing.stl'
|
||||
dest_path = Path('/tmp/test.stl')
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
test_url,
|
||||
status=404
|
||||
)
|
||||
|
||||
result = download_file(test_url, dest_path)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_github_api_get_file_sha(github_api):
|
||||
"""Test getting file SHA from GitHub API."""
|
||||
owner = 'test-owner'
|
||||
repo = 'test-repo'
|
||||
path = 'file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock Contents API response
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha-123'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
# Mock Commits API response
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': 'commit-sha-456'}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
sha = github_api.get_file_sha(owner, repo, path, ref)
|
||||
|
||||
assert sha == 'commit-sha-456'
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_github_api_get_license(github_api):
|
||||
"""Test getting license information."""
|
||||
owner = 'test-owner'
|
||||
repo = 'test-repo'
|
||||
sha = 'abc123'
|
||||
|
||||
# Mock LICENSE file found
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/LICENSE',
|
||||
json={'type': 'file'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': sha})]
|
||||
)
|
||||
|
||||
license_url = github_api.get_license(owner, repo, sha)
|
||||
|
||||
assert license_url == f'https://raw.githubusercontent.com/{owner}/{repo}/{sha}/LICENSE'
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_update_manifest_entry_dry_run(temp_dir, sample_manifest_entry):
|
||||
"""Test updating manifest entry in dry-run mode."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
|
||||
# Mock API responses
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': 'commit-sha-123'}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
api = GitHubAPI(token='test-token')
|
||||
updated_entry = update_manifest_entry(
|
||||
sample_manifest_entry,
|
||||
api,
|
||||
temp_dir,
|
||||
dry_run=True
|
||||
)
|
||||
|
||||
assert updated_entry['pinned_sha'] == 'commit-sha-123'
|
||||
assert updated_entry['pinned_raw_url'] == f'https://raw.githubusercontent.com/{owner}/{repo}/commit-sha-123/{path}'
|
||||
assert updated_entry['status'] == 'up-to-date'
|
||||
assert updated_entry['last_checked'] is not None
|
||||
assert updated_entry['upstream_latest_sha'] == 'commit-sha-123'
|
||||
|
||||
# In dry-run, file should not be downloaded
|
||||
local_path = temp_dir / updated_entry['local_path']
|
||||
assert not local_path.exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_update_manifest_entry_with_download(temp_dir, sample_manifest_entry):
|
||||
"""Test updating manifest entry with actual download."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
commit_sha = 'commit-sha-123'
|
||||
file_content = b'STL file content here'
|
||||
|
||||
# Mock API responses
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': commit_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
# Mock file download
|
||||
pinned_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{commit_sha}/{path}'
|
||||
responses.add(
|
||||
responses.GET,
|
||||
pinned_url,
|
||||
body=file_content,
|
||||
status=200
|
||||
)
|
||||
|
||||
api = GitHubAPI(token='test-token')
|
||||
updated_entry = update_manifest_entry(
|
||||
sample_manifest_entry,
|
||||
api,
|
||||
temp_dir,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert updated_entry['pinned_sha'] == commit_sha
|
||||
assert updated_entry['checksum_sha256'] is not None
|
||||
assert updated_entry['status'] == 'up-to-date'
|
||||
|
||||
# Verify file was downloaded
|
||||
local_path = temp_dir / updated_entry['local_path']
|
||||
assert local_path.exists()
|
||||
assert local_path.read_bytes() == file_content
|
||||
|
||||
# Verify checksum
|
||||
expected_checksum = hashlib.sha256(file_content).hexdigest()
|
||||
assert updated_entry['checksum_sha256'] == expected_checksum
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_update_manifest_entry_download_failure(temp_dir, sample_manifest_entry):
|
||||
"""Test handling of download failure."""
|
||||
owner = 'owner'
|
||||
repo = 'repo'
|
||||
path = 'path/to/file.stl'
|
||||
ref = 'main'
|
||||
commit_sha = 'commit-sha-123'
|
||||
|
||||
# Mock API responses
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/contents/{path}',
|
||||
json={'sha': 'blob-sha'},
|
||||
match=[responses.matchers.query_param_matcher({'ref': ref})]
|
||||
)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f'https://api.github.com/repos/{owner}/{repo}/commits',
|
||||
json=[{'sha': commit_sha}],
|
||||
match=[responses.matchers.query_param_matcher({
|
||||
'path': path,
|
||||
'sha': ref,
|
||||
'per_page': 1
|
||||
})]
|
||||
)
|
||||
|
||||
# Mock file download failure
|
||||
pinned_url = f'https://raw.githubusercontent.com/{owner}/{repo}/{commit_sha}/{path}'
|
||||
responses.add(
|
||||
responses.GET,
|
||||
pinned_url,
|
||||
status=404
|
||||
)
|
||||
|
||||
api = GitHubAPI(token='test-token')
|
||||
updated_entry = update_manifest_entry(
|
||||
sample_manifest_entry,
|
||||
api,
|
||||
temp_dir,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
assert updated_entry['status'] == 'error'
|
||||
assert updated_entry['pinned_sha'] == commit_sha # SHA was resolved
|
||||
assert updated_entry['checksum_sha256'] is None # File not downloaded
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Handle.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Handle.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Lower V1.1.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Lower V1.1.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Upper V1.1.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Mounting/OSSM - Base - PitClamp Mini - Upper V1.1.stl
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Body.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Body.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl
vendored
Normal file
Binary file not shown.
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Top Cover.stl
vendored
Normal file
BIN
vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Top Cover.stl
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
38
website/eslint.config.js
Normal file
38
website/eslint.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
2482
website/package-lock.json
generated
2482
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,26 +6,34 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"exceljs": "^4.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"xlsx": "^0.18.5"
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^17.0.0",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.9",
|
||||
"vite": "^5.4.2"
|
||||
"vite": "^7.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"glob": "^9.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"inflight": "npm:@jsdevtools/inflight@^1.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
website/public/images/options/3030-pcb-mount.png
Normal file
BIN
website/public/images/options/3030-pcb-mount.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
@@ -2,14 +2,21 @@ import { useState, useEffect } from 'react';
|
||||
import MainPage from './components/MainPage';
|
||||
import Wizard from './components/Wizard';
|
||||
import ThemeToggle from './components/ThemeToggle';
|
||||
import CurrencySwitcher from './components/CurrencySwitcher';
|
||||
import partsData from './data/index.js';
|
||||
import { getSharedConfig } from './utils/shareService';
|
||||
|
||||
function App() {
|
||||
const [buildType, setBuildType] = useState(null);
|
||||
// Determine initial recommended parts
|
||||
const recommendedMotor = partsData.motors.find(m => m.recommended) || partsData.motors[0];
|
||||
const recommendedPSU = partsData.powerSupplies.find(psu =>
|
||||
psu.compatibleMotors.includes(recommendedMotor.id)
|
||||
) || partsData.powerSupplies[0];
|
||||
|
||||
const [config, setConfig] = useState({
|
||||
motor: null,
|
||||
powerSupply: null,
|
||||
motor: recommendedMotor,
|
||||
powerSupply: recommendedPSU,
|
||||
primaryColor: 'black',
|
||||
accentColor: 'black',
|
||||
mount: null,
|
||||
@@ -29,7 +36,7 @@ function App() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const shareId = urlParams.get('share');
|
||||
const isSession = urlParams.get('session') === 'true';
|
||||
|
||||
|
||||
if (shareId) {
|
||||
const sharedConfig = getSharedConfig(shareId, isSession);
|
||||
if (sharedConfig) {
|
||||
@@ -63,10 +70,10 @@ function App() {
|
||||
// - Standard colors (black/black)
|
||||
// - Basic stand components
|
||||
// - Default toy mount options (flange mount base)
|
||||
|
||||
|
||||
const motor = partsData.motors.find(m => m.id === '57AIM30') || partsData.motors[0];
|
||||
const powerSupply = partsData.powerSupplies.find(ps => ps.id === 'psu-24v-5a') || partsData.powerSupplies[0];
|
||||
|
||||
|
||||
// Get mount from options data to ensure proper structure
|
||||
const mountOptions = partsData.options?.actuator?.sections?.mounts?.options || [];
|
||||
const mount = mountOptions.find(m => m.id === 'middle-pivot') || mountOptions[0] || null;
|
||||
@@ -127,9 +134,14 @@ function App() {
|
||||
|
||||
const handleBackToMain = () => {
|
||||
setBuildType(null);
|
||||
const defaultMotor = partsData.motors.find(m => m.recommended) || partsData.motors[0];
|
||||
const defaultPSU = partsData.powerSupplies.find(psu =>
|
||||
psu.compatibleMotors.includes(defaultMotor.id)
|
||||
) || partsData.powerSupplies[0];
|
||||
|
||||
setConfig({
|
||||
motor: null,
|
||||
powerSupply: null,
|
||||
motor: defaultMotor,
|
||||
powerSupply: defaultPSU,
|
||||
primaryColor: 'black',
|
||||
accentColor: 'black',
|
||||
mount: null,
|
||||
@@ -149,6 +161,7 @@ function App() {
|
||||
return (
|
||||
<>
|
||||
<ThemeToggle />
|
||||
<CurrencySwitcher />
|
||||
<MainPage onSelectBuildType={handleSelectBuildType} />
|
||||
</>
|
||||
);
|
||||
@@ -157,6 +170,7 @@ function App() {
|
||||
return (
|
||||
<>
|
||||
<ThemeToggle />
|
||||
<CurrencySwitcher />
|
||||
<Wizard
|
||||
buildType={buildType}
|
||||
initialConfig={config}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
201
website/src/components/BOMSummary/HardwareTab.jsx
Normal file
201
website/src/components/BOMSummary/HardwareTab.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DataTable from '../ui/DataTable';
|
||||
import AsyncPrice from '../ui/AsyncPrice';
|
||||
|
||||
/**
|
||||
* Hardware table row renderer for unified view with currency conversion
|
||||
*/
|
||||
const renderUnifiedHardwareRow = (part) => {
|
||||
// Handle item references (motor, PSU, PCB) - convert from links
|
||||
const priceToDisplay = part.price;
|
||||
const hasPrice = priceToDisplay && (
|
||||
(typeof priceToDisplay === 'object' && (priceToDisplay.links || priceToDisplay.amount)) ||
|
||||
(typeof priceToDisplay === 'number' && priceToDisplay > 0) ||
|
||||
(typeof priceToDisplay === 'string' && priceToDisplay !== '$0.00' && priceToDisplay !== 'C$0.00' && priceToDisplay !== '0.00')
|
||||
);
|
||||
|
||||
return (
|
||||
<tr key={part.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{part.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{part.description || '-'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{part.quantity || 1}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
{hasPrice ? (
|
||||
<AsyncPrice
|
||||
price={priceToDisplay}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
fallback="-"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hardware table row renderer for expanded view with currency conversion
|
||||
*/
|
||||
const renderExpandedHardwareRow = (part) => {
|
||||
// Handle item references (motor, PSU, PCB) - convert from links
|
||||
const priceToDisplay = part.price;
|
||||
const hasPrice = priceToDisplay && (
|
||||
(typeof priceToDisplay === 'object' && (priceToDisplay.links || priceToDisplay.amount)) ||
|
||||
(typeof priceToDisplay === 'number' && priceToDisplay > 0) ||
|
||||
(typeof priceToDisplay === 'string' && priceToDisplay !== '$0.00' && priceToDisplay !== 'C$0.00' && priceToDisplay !== '0.00')
|
||||
);
|
||||
|
||||
return (
|
||||
<tr key={part.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{part.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{part.description || '-'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
|
||||
{part.hardwareType || 'Other Hardware'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{part.quantity || 1}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
{hasPrice ? (
|
||||
<AsyncPrice
|
||||
price={priceToDisplay}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
fallback="-"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hardware tab component for BOM Summary
|
||||
*/
|
||||
export default function HardwareTab({ hardwareParts, expandedHardwareByComponent }) {
|
||||
const [hardwareViewMode, setHardwareViewMode] = useState('unified'); // 'unified' or 'expanded'
|
||||
|
||||
// Group hardware parts by type for unified view
|
||||
const hardwareByType = hardwareParts.reduce((acc, part) => {
|
||||
const type = part.hardwareType || 'Other Hardware';
|
||||
if (!acc[type]) {
|
||||
acc[type] = [];
|
||||
}
|
||||
acc[type].push(part);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const unifiedColumns = [
|
||||
{ key: 'name', label: 'Part Name', align: 'left' },
|
||||
{ key: 'description', label: 'Description', align: 'left' },
|
||||
{ key: 'quantity', label: 'Quantity', align: 'right' },
|
||||
{ key: 'price', label: 'Price', align: 'right' },
|
||||
];
|
||||
|
||||
const expandedColumns = [
|
||||
{ key: 'name', label: 'Part Name', align: 'left' },
|
||||
{ key: 'description', label: 'Description', align: 'left' },
|
||||
{ key: 'type', label: 'Type', align: 'left' },
|
||||
{ key: 'quantity', label: 'Quantity', align: 'right' },
|
||||
{ key: 'price', label: 'Price', align: 'right' },
|
||||
];
|
||||
|
||||
// Sort order for hardware types
|
||||
const sortHardwareTypes = (a, b) => {
|
||||
const order = ['Fasteners', 'Motion Components', 'Aluminum Extrusion', 'Electronics', 'Other Hardware'];
|
||||
const indexA = order.indexOf(a);
|
||||
const indexB = order.indexOf(b);
|
||||
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
|
||||
if (indexA === -1) return 1;
|
||||
if (indexB === -1) return -1;
|
||||
return indexA - indexB;
|
||||
};
|
||||
|
||||
if (hardwareParts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<p>No hardware parts required for this configuration.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Required Hardware Parts</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setHardwareViewMode('unified')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${hardwareViewMode === 'unified'
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Unified View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHardwareViewMode('expanded')}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${hardwareViewMode === 'expanded'
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Expanded View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{hardwareViewMode === 'unified' ? (
|
||||
// Unified view: Group by hardware type
|
||||
Object.entries(hardwareByType)
|
||||
.sort(([a], [b]) => sortHardwareTypes(a, b))
|
||||
.map(([type, parts]) => (
|
||||
<div key={type}>
|
||||
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300 mb-3">{type}</h4>
|
||||
<DataTable
|
||||
columns={unifiedColumns}
|
||||
data={parts}
|
||||
renderRow={renderUnifiedHardwareRow}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
// Expanded view: Group by component BOMs
|
||||
expandedHardwareByComponent.map(({ component, parts }) => (
|
||||
<div key={component}>
|
||||
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300 mb-3">{component}</h4>
|
||||
<DataTable
|
||||
columns={expandedColumns}
|
||||
data={parts}
|
||||
renderRow={renderExpandedHardwareRow}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
HardwareTab.propTypes = {
|
||||
hardwareParts: PropTypes.array.isRequired,
|
||||
expandedHardwareByComponent: PropTypes.array.isRequired,
|
||||
};
|
||||
254
website/src/components/BOMSummary/OverviewTab.jsx
Normal file
254
website/src/components/BOMSummary/OverviewTab.jsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImageWithFallback from '../ui/ImageWithFallback';
|
||||
import FilamentDisplay from '../ui/FilamentDisplay';
|
||||
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import { formatPriceWithConversion } from '../../utils/priceFormat';
|
||||
import partsData from '../../data/index.js';
|
||||
|
||||
/**
|
||||
* Overview tab component for BOM Summary
|
||||
*/
|
||||
export default function OverviewTab({
|
||||
config,
|
||||
filamentTotals,
|
||||
totalTime,
|
||||
total,
|
||||
getColorName,
|
||||
getColorHex
|
||||
}) {
|
||||
const { currency, exchangeRates } = useCurrency();
|
||||
const [motorPrice, setMotorPrice] = useState('');
|
||||
const [psuPrice, setPsuPrice] = useState('');
|
||||
const [totalPrice, setTotalPrice] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const updatePrices = async () => {
|
||||
if (config.motor) {
|
||||
const price = await getPriceDisplayFromLinksAsync(config.motor, currency, exchangeRates);
|
||||
setMotorPrice(price);
|
||||
} else {
|
||||
setMotorPrice('');
|
||||
}
|
||||
|
||||
if (config.powerSupply) {
|
||||
const price = await getPriceDisplayFromLinksAsync(config.powerSupply, currency, exchangeRates);
|
||||
setPsuPrice(price);
|
||||
} else {
|
||||
setPsuPrice('');
|
||||
}
|
||||
|
||||
if (total !== undefined && total !== null) {
|
||||
const formatted = await formatPriceWithConversion(total, currency, exchangeRates);
|
||||
setTotalPrice(formatted);
|
||||
}
|
||||
};
|
||||
|
||||
updatePrices();
|
||||
}, [config.motor, config.powerSupply, total, currency, exchangeRates]);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hardware (Motor & Power Supply) */}
|
||||
{(config.motor || config.powerSupply) && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Hardware</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{config.motor && (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={config.motor.image}
|
||||
alt={config.motor.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{config.motor.name}
|
||||
</span>
|
||||
{motorPrice && (
|
||||
<span className="text-xs text-center text-gray-600 dark:text-gray-400 mt-1">
|
||||
{motorPrice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{config.powerSupply && (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={config.powerSupply.image}
|
||||
alt={config.powerSupply.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{config.powerSupply.name}
|
||||
</span>
|
||||
{psuPrice && (
|
||||
<span className="text-xs text-center text-gray-600 dark:text-gray-400 mt-1">
|
||||
{psuPrice}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filament Usage */}
|
||||
<FilamentDisplay
|
||||
filamentTotals={filamentTotals}
|
||||
totalTime={totalTime}
|
||||
primaryColor={config.primaryColor}
|
||||
accentColor={config.accentColor}
|
||||
getColorName={getColorName}
|
||||
getColorHex={getColorHex}
|
||||
/>
|
||||
|
||||
{/* Selected Options/Kit */}
|
||||
{(config.mount || config.cover || config.pcbMount || config.standHinge || config.standFeet ||
|
||||
(config.standCrossbarSupports && config.standCrossbarSupports.length > 0) ||
|
||||
(config.remoteType || config.remote?.id)) && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Selected Options</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{config.mount && (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={config.mount.image}
|
||||
alt={config.mount.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{config.mount.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{config.cover && (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={config.cover.image}
|
||||
alt={config.cover.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{config.cover.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{config.pcbMount && (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={config.pcbMount.image}
|
||||
alt={config.pcbMount.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{config.pcbMount.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{config.standHinge && (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={config.standHinge.image}
|
||||
alt={config.standHinge.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{config.standHinge.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{config.standFeet && (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={config.standFeet.image}
|
||||
alt={config.standFeet.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{config.standFeet.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{config.standCrossbarSupports && config.standCrossbarSupports.length > 0 && (
|
||||
<>
|
||||
{config.standCrossbarSupports.map((support) => (
|
||||
<div key={support.id} className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={support.image}
|
||||
alt={support.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{support.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{(config.remoteType || config.remote?.id) && (() => {
|
||||
const remoteId = config.remoteType || config.remote?.id;
|
||||
const remoteSystem = partsData.components?.remotes?.systems?.[remoteId];
|
||||
return remoteSystem ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={remoteSystem.image}
|
||||
alt={remoteSystem.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{remoteSystem.name}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toy Mounts */}
|
||||
{config.toyMountOptions && config.toyMountOptions.length > 0 && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Toy Mounts</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{config.toyMountOptions.map((toyMount) => (
|
||||
<div key={toyMount.id} className="flex flex-col items-center">
|
||||
<ImageWithFallback
|
||||
src={toyMount.image}
|
||||
alt={toyMount.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2"
|
||||
/>
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{toyMount.name}
|
||||
</span>
|
||||
{toyMount.description && (
|
||||
<span className="text-xs text-center text-gray-500 dark:text-gray-400 mt-1">
|
||||
{toyMount.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total */}
|
||||
<div className="pt-4 border-t-2 border-gray-300 dark:border-gray-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">Total Hardware Cost</h3>
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{totalPrice || '...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OverviewTab.propTypes = {
|
||||
config: PropTypes.object.isRequired,
|
||||
filamentTotals: PropTypes.object.isRequired,
|
||||
totalTime: PropTypes.string.isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
getColorName: PropTypes.func,
|
||||
getColorHex: PropTypes.func,
|
||||
};
|
||||
222
website/src/components/BOMSummary/PrintedPartsTab.jsx
Normal file
222
website/src/components/BOMSummary/PrintedPartsTab.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import DataTable from '../ui/DataTable';
|
||||
import FilamentDisplay from '../ui/FilamentDisplay';
|
||||
import { getColorName, getColorHex } from '../../utils/partUtils';
|
||||
|
||||
/**
|
||||
* Printed parts table row renderer
|
||||
*/
|
||||
const renderPrintedPartRow = (part, config, getColorNameFunc, getColorHexFunc) => {
|
||||
const partColour = part.colour || 'primary';
|
||||
const colorHex = getColorHexFunc(
|
||||
partColour === 'primary' ? config.primaryColor : config.accentColor,
|
||||
partColour
|
||||
);
|
||||
const colorName = getColorNameFunc(
|
||||
partColour === 'primary' ? config.primaryColor : config.accentColor,
|
||||
partColour
|
||||
);
|
||||
|
||||
const formatFilamentEstimate = (estimate, quantity) => {
|
||||
if (!estimate || estimate === 0) return null;
|
||||
const total = typeof estimate === 'number'
|
||||
? (estimate * quantity).toFixed(1)
|
||||
: estimate;
|
||||
const perUnit = typeof estimate === 'number'
|
||||
? estimate.toFixed(1)
|
||||
: parseFloat(estimate.replace(/[~g]/g, '').trim()) || 0;
|
||||
return {
|
||||
total: `${total}g`,
|
||||
perUnit: quantity > 1 ? `(${perUnit.toFixed(1)}g × ${quantity})` : null
|
||||
};
|
||||
};
|
||||
|
||||
const filamentData = formatFilamentEstimate(part.filamentEstimate, part.quantity || 1);
|
||||
|
||||
return (
|
||||
<tr key={part.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{part.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: colorHex }}
|
||||
title={`${partColour === 'primary' ? 'Primary' : 'Secondary'} color: ${colorName}`}
|
||||
/>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 capitalize">{partColour}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{part.description || '-'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{part.isHardwareOnly ? (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400 italic">Hardware only</span>
|
||||
) : part.filePath ? (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono">{part.filePath}</p>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{part.quantity || 1}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
{part.isHardwareOnly ? (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400">-</span>
|
||||
) : filamentData ? (
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{filamentData.total}
|
||||
{filamentData.perUnit && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
||||
{filamentData.perUnit}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Printed Parts tab component for BOM Summary
|
||||
*/
|
||||
export default function PrintedPartsTab({
|
||||
printedParts,
|
||||
config,
|
||||
filamentTotals,
|
||||
totalTime
|
||||
}) {
|
||||
// Group parts by category
|
||||
const partsByCategory = printedParts.reduce((acc, part) => {
|
||||
if (!acc[part.category]) {
|
||||
acc[part.category] = [];
|
||||
}
|
||||
acc[part.category].push(part);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Define main sections and their subcategories
|
||||
const mainSections = {
|
||||
'Actuator + Mount': ['Actuator Body', 'Mount', 'Cover', 'PCB Mount'],
|
||||
'Stand': ['Stand', 'Stand Hinges', 'Stand Feet', 'Stand Crossbar Supports'],
|
||||
'Remote': ['Remote Body', 'Remote Knobs'],
|
||||
};
|
||||
|
||||
// Helper to check if a section has any parts
|
||||
const sectionHasParts = (subcategories) => {
|
||||
return subcategories.some(cat => partsByCategory[cat] && partsByCategory[cat].length > 0);
|
||||
};
|
||||
|
||||
const printedPartsColumns = [
|
||||
{ key: 'name', label: 'Part Name', align: 'left' },
|
||||
{ key: 'color', label: 'Color', align: 'left' },
|
||||
{ key: 'description', label: 'Description', align: 'left' },
|
||||
{ key: 'filePath', label: 'File Path', align: 'left' },
|
||||
{ key: 'quantity', label: 'Quantity', align: 'right' },
|
||||
{ key: 'filament', label: 'Filament', align: 'right' },
|
||||
];
|
||||
|
||||
if (printedParts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<p>No printed parts required for this configuration.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Required Printed Parts</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(mainSections).map(([mainSectionName, subcategories]) => {
|
||||
if (!sectionHasParts(subcategories)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={mainSectionName} className="border-l-2 border-blue-200 dark:border-blue-700 pl-4">
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">{mainSectionName}</h4>
|
||||
<div className="space-y-4 ml-2">
|
||||
{subcategories.map((category) => {
|
||||
const parts = partsByCategory[category];
|
||||
if (!parts || parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h5 className="text-md font-medium text-gray-700 dark:text-gray-300">{category}</h5>
|
||||
{parts.some(p => p.replacesActuatorMiddle) && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-1 rounded">
|
||||
Replaces standard ossm-actuator-body-middle
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={printedPartsColumns}
|
||||
data={parts}
|
||||
renderRow={(part) => renderPrintedPartRow(part, config, getColorName, getColorHex)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Other categories not in main sections (e.g., Toy Mounts) */}
|
||||
{Object.entries(partsByCategory).map(([category, parts]) => {
|
||||
const isInMainSection = Object.values(mainSections).flat().includes(category);
|
||||
if (isInMainSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h4 className="text-md font-medium text-gray-700 dark:text-gray-300">{category}</h4>
|
||||
{parts.some(p => p.replacesActuatorMiddle) && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 px-2 py-1 rounded">
|
||||
Replaces standard ossm-actuator-body-middle
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={printedPartsColumns}
|
||||
data={parts}
|
||||
renderRow={(part) => renderPrintedPartRow(part, config, getColorName, getColorHex)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<FilamentDisplay
|
||||
filamentTotals={filamentTotals}
|
||||
totalTime={totalTime}
|
||||
primaryColor={config.primaryColor}
|
||||
accentColor={config.accentColor}
|
||||
getColorName={getColorName}
|
||||
getColorHex={getColorHex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PrintedPartsTab.propTypes = {
|
||||
printedParts: PropTypes.array.isRequired,
|
||||
config: PropTypes.object.isRequired,
|
||||
filamentTotals: PropTypes.object.isRequired,
|
||||
totalTime: PropTypes.string.isRequired,
|
||||
};
|
||||
36
website/src/components/BOMSummary/ShareButton.jsx
Normal file
36
website/src/components/BOMSummary/ShareButton.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { createShareLink } from '../../utils/shareService';
|
||||
|
||||
/**
|
||||
* Share button component for creating shareable links
|
||||
*/
|
||||
export default function ShareButton({ config }) {
|
||||
const handleShare = () => {
|
||||
try {
|
||||
const shareUrl = createShareLink(config);
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
alert(`Share link copied to clipboard!\n\n${shareUrl}\n\nThis link will expire in 7 days.`);
|
||||
}).catch(() => {
|
||||
// Fallback: show the URL in a prompt
|
||||
prompt('Share link (valid for 7 days):', shareUrl);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating share link:', error);
|
||||
alert('Error creating share link. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="w-full px-6 py-3 bg-purple-600 dark:bg-purple-500 text-white rounded-lg font-medium hover:bg-purple-700 dark:hover:bg-purple-600 transition-colors"
|
||||
>
|
||||
Share Link (7 days)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
ShareButton.propTypes = {
|
||||
config: PropTypes.object.isRequired,
|
||||
};
|
||||
88
website/src/components/CurrencySwitcher.jsx
Normal file
88
website/src/components/CurrencySwitcher.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { useCurrency } from '../contexts/CurrencyContext';
|
||||
|
||||
const currencies = [
|
||||
{ code: 'USD', symbol: '$', name: 'US Dollar' },
|
||||
{ code: 'CAD', symbol: 'C$', name: 'Canadian Dollar' },
|
||||
{ code: 'EUR', symbol: '€', name: 'Euro' },
|
||||
{ code: 'GBP', symbol: '£', name: 'British Pound' },
|
||||
{ code: 'AUD', symbol: 'A$', name: 'Australian Dollar' },
|
||||
{ code: 'JPY', symbol: '¥', name: 'Japanese Yen' },
|
||||
{ code: 'CNY', symbol: '¥', name: 'Chinese Yuan' },
|
||||
];
|
||||
|
||||
export default function CurrencySwitcher() {
|
||||
const { currency, setCurrency } = useCurrency();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const currentCurrency = currencies.find(c => c.code === currency) || currencies[0];
|
||||
|
||||
const handleCurrencyChange = (newCurrency) => {
|
||||
setCurrency(newCurrency);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-20 sm:right-24 z-50">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 sm:p-3 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center gap-1 sm:gap-2 min-w-[70px] sm:min-w-[80px] justify-center"
|
||||
aria-label="Change currency"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="text-xs sm:text-sm font-semibold text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{currentCurrency.symbol} {currentCurrency.code}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-600 dark:text-gray-400 transition-transform flex-shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="py-1">
|
||||
{currencies.map((curr) => (
|
||||
<button
|
||||
key={curr.code}
|
||||
onClick={() => handleCurrencyChange(curr.code)}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors ${
|
||||
currency === curr.code
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-semibold'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{curr.symbol}</span>
|
||||
<span className="ml-2">{curr.code}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{curr.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
website/src/components/Footer.jsx
Normal file
12
website/src/components/Footer.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full py-8 mt-12 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Disclaimer: The OSSM Configurator is an independent open-source project.
|
||||
We are not directly associated with the vendors listed, but their contributions to open source are deeply appreciated and reciprocated.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import partsData from '../data/index.js';
|
||||
import Footer from './Footer';
|
||||
|
||||
export default function MainPage({ onSelectBuildType }) {
|
||||
const handleSelect = (buildType) => {
|
||||
@@ -6,8 +6,8 @@ export default function MainPage({ onSelectBuildType }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8 flex flex-col">
|
||||
<div className="max-w-4xl mx-auto px-4 flex-grow">
|
||||
{/* Header */}
|
||||
<div className="mb-12 text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
@@ -23,7 +23,7 @@ export default function MainPage({ onSelectBuildType }) {
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
Select Your Build Type
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{/* New Build - RAD Kit */}
|
||||
<button
|
||||
@@ -120,6 +120,7 @@ export default function MainPage({ onSelectBuildType }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="fixed top-4 right-4 z-50 p-3 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110"
|
||||
className="fixed top-4 right-4 z-50 p-3 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import OptionsStep from './steps/OptionsStep';
|
||||
import RemoteStep from './steps/RemoteStep';
|
||||
import ToyMountStep from './steps/ToyMountStep';
|
||||
import BOMSummary from './BOMSummary';
|
||||
import Footer from './Footer';
|
||||
|
||||
const steps = [
|
||||
{ id: 'motor', name: 'Motor', component: MotorStep },
|
||||
@@ -29,15 +30,15 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(getInitialStep());
|
||||
const [config, setConfig] = useState(initialConfig || {
|
||||
motor: '57AIM30',
|
||||
powerSupply: '24V PSU',
|
||||
motor: null,
|
||||
powerSupply: null,
|
||||
primaryColor: 'black',
|
||||
accentColor: 'black',
|
||||
mount: 'Middle Pivot',
|
||||
cover: 'Simple',
|
||||
standHinge: 'Pivot Plate',
|
||||
standFeet: '3030 Extrusion',
|
||||
standCrossbarSupports: 'standard',
|
||||
mount: null,
|
||||
cover: null,
|
||||
standHinge: null,
|
||||
standFeet: null,
|
||||
standCrossbarSupports: [],
|
||||
pcbMount: null,
|
||||
});
|
||||
|
||||
@@ -87,7 +88,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
// Power Supply step - require power supply selection
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (currentStep < filteredSteps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
@@ -104,17 +105,17 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
if (stepIndex <= currentStep) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// In upgrade mode, no validation needed
|
||||
if (buildType === 'upgrade') {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// In RAD Kit mode, all steps are pre-selected, so navigation is always allowed
|
||||
if (buildType === 'rad-kit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check if required steps are completed before jumping ahead
|
||||
if (stepIndex > 0 && !config.motor) {
|
||||
return false; // Can't skip motor step
|
||||
@@ -122,7 +123,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
if (stepIndex > 1 && !config.powerSupply) {
|
||||
return false; // Can't skip power supply step
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -137,7 +138,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
if (buildType === 'upgrade') {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
if (currentStep === 0 && !config.motor) {
|
||||
return false; // Motor step - require motor selection
|
||||
}
|
||||
@@ -158,8 +159,8 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
}, [buildType, currentStep]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8 flex flex-col">
|
||||
<div className="max-w-4xl mx-auto px-4 flex-grow w-full">
|
||||
{/* Back Button */}
|
||||
{onBackToMain && (
|
||||
<div className="mb-4">
|
||||
@@ -191,7 +192,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
OSSM Configurator
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{buildType === 'upgrade'
|
||||
{buildType === 'upgrade'
|
||||
? 'Select upgrade components and modifications'
|
||||
: 'Configure your Open Source Sex Machine'}
|
||||
</p>
|
||||
@@ -212,17 +213,15 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
{/* Circle */}
|
||||
<button
|
||||
onClick={() => goToStep(index)}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold flex-shrink-0 z-10 ${
|
||||
index === currentStep
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white'
|
||||
: index < currentStep
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold flex-shrink-0 z-10 ${index === currentStep
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white'
|
||||
: index < currentStep
|
||||
? 'bg-green-500 dark:bg-green-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
} ${
|
||||
index <= currentStep
|
||||
} ${index <= currentStep
|
||||
? 'cursor-pointer hover:opacity-80'
|
||||
: 'cursor-not-allowed'
|
||||
}`}
|
||||
}`}
|
||||
disabled={!canNavigateToStep(index)}
|
||||
>
|
||||
{index < currentStep ? '✓' : index + 1}
|
||||
@@ -230,10 +229,9 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
{/* Connecting line to the right */}
|
||||
{index < filteredSteps.length - 1 && (
|
||||
<div
|
||||
className={`absolute top-5 left-1/2 h-1 ${
|
||||
index < currentStep ? 'bg-green-500 dark:bg-green-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
style={{
|
||||
className={`absolute top-5 left-1/2 h-1 ${index < currentStep ? 'bg-green-500 dark:bg-green-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
style={{
|
||||
width: 'calc(100% - 40px)',
|
||||
marginLeft: '20px'
|
||||
}}
|
||||
@@ -242,11 +240,10 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
{/* Text label */}
|
||||
<button
|
||||
onClick={() => goToStep(index)}
|
||||
className={`mt-2 text-sm font-medium text-center ${
|
||||
index <= currentStep
|
||||
? 'text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-800 dark:hover:text-blue-300'
|
||||
: 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
className={`mt-2 text-sm font-medium text-center ${index <= currentStep
|
||||
? 'text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-800 dark:hover:text-blue-300'
|
||||
: 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!canNavigateToStep(index)}
|
||||
>
|
||||
{step.name}
|
||||
@@ -273,11 +270,10 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
<button
|
||||
onClick={prevStep}
|
||||
disabled={currentStep === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium ${
|
||||
currentStep === 0
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
className={`px-6 py-2 rounded-lg font-medium ${currentStep === 0
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
@@ -285,11 +281,10 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
<button
|
||||
onClick={nextStep}
|
||||
disabled={!canProceedToNextStep()}
|
||||
className={`px-6 py-2 rounded-lg font-medium ${
|
||||
canProceedToNextStep()
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
className={`px-6 py-2 rounded-lg font-medium ${canProceedToNextStep()
|
||||
? 'bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@@ -297,6 +292,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import ImageWithFallback from '../ui/ImageWithFallback';
|
||||
import AsyncPrice from '../ui/AsyncPrice';
|
||||
|
||||
export default function MotorStep({ config, updateConfig }) {
|
||||
const selectedMotorId = config.motor?.id;
|
||||
const { currency, exchangeRates } = useCurrency();
|
||||
const [motorPrices, setMotorPrices] = useState({});
|
||||
|
||||
const handleSelect = (motor) => {
|
||||
updateConfig({ motor });
|
||||
@@ -12,37 +19,46 @@ export default function MotorStep({ config, updateConfig }) {
|
||||
const otherMotors = partsData.motors.filter(m => !m.recommended);
|
||||
const hasSingleRecommended = recommendedMotors.length === 1;
|
||||
|
||||
const renderMotorCard = (motor, isRecommended = false, isSlightlyLarger = false) => (
|
||||
useEffect(() => {
|
||||
const updatePrices = async () => {
|
||||
if (!exchangeRates) return; // Wait for rates to load
|
||||
const prices = {};
|
||||
// Update prices for all motors
|
||||
for (const m of partsData.motors) {
|
||||
if (m.links && m.links.length > 0) {
|
||||
const price = await getPriceDisplayFromLinksAsync(m, currency, exchangeRates);
|
||||
prices[m.id] = price;
|
||||
}
|
||||
}
|
||||
setMotorPrices(prices);
|
||||
};
|
||||
updatePrices();
|
||||
}, [currency, exchangeRates]);
|
||||
|
||||
const renderMotorCard = (motor, isSlightlyLarger = false) => (
|
||||
<button
|
||||
key={motor.id}
|
||||
onClick={() => handleSelect(motor)}
|
||||
className={`${isSlightlyLarger ? 'p-5' : 'p-4'} border-2 rounded-lg text-left transition-all ${
|
||||
selectedMotorId === motor.id
|
||||
className={`w-full ${isSlightlyLarger ? 'p-5' : 'p-4'} border-2 rounded-lg text-left transition-all ${selectedMotorId === motor.id
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg'
|
||||
: motor.recommended
|
||||
? 'border-green-500 dark:border-green-600 bg-green-50 dark:bg-green-900/30 hover:border-green-600 dark:hover:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
? 'border-green-500 dark:border-green-600 bg-green-50 dark:bg-green-900/30 hover:border-green-600 dark:hover:border-green-500 hover:bg-green-100 dark:hover:bg-green-900/40'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
{motor.recommended && (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="mb-3 flex items-center justify-center gap-2">
|
||||
<span className="inline-flex items-center px-3 py-1 text-xs font-semibold text-green-800 dark:text-green-300 bg-green-200 dark:bg-green-900/50 rounded-full">
|
||||
⭐ Recommended
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{motor.image && (
|
||||
<div className={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}>
|
||||
<img
|
||||
src={motor.image}
|
||||
alt={motor.name}
|
||||
className={`${isSlightlyLarger ? 'h-32 w-32' : 'h-24 w-24'} object-contain rounded-lg bg-gray-100 dark:bg-gray-700`}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ImageWithFallback
|
||||
src={motor.image}
|
||||
alt={motor.name}
|
||||
className={`${isSlightlyLarger ? 'h-32 w-32' : 'h-24 w-24'} object-contain rounded-lg bg-gray-100 dark:bg-gray-700`}
|
||||
containerClassName={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}
|
||||
/>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className={`${isSlightlyLarger ? 'text-lg' : 'text-base'} font-semibold text-gray-900 dark:text-white`}>
|
||||
{motor.name}
|
||||
@@ -66,7 +82,7 @@ export default function MotorStep({ config, updateConfig }) {
|
||||
)}
|
||||
</div>
|
||||
<p className={`${isSlightlyLarger ? 'text-sm' : 'text-sm'} text-gray-600 dark:text-gray-300 mb-3`}>{motor.description}</p>
|
||||
<div className={`flex ${isSlightlyLarger ? 'gap-4' : 'gap-3'} text-sm`}>
|
||||
<div className={`flex ${isSlightlyLarger ? 'gap-4' : 'gap-3'} text-sm mb-3`}>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Speed:</span>{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{motor.speed}</span>
|
||||
@@ -80,42 +96,69 @@ export default function MotorStep({ config, updateConfig }) {
|
||||
<span className="font-medium text-gray-900 dark:text-white">{motor.gear_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${isSlightlyLarger ? 'mt-3' : 'mt-2'} flex items-center justify-between`}>
|
||||
<div className={`${isSlightlyLarger ? 'text-lg' : 'text-lg'} font-bold text-blue-600 dark:text-blue-400`}>
|
||||
{formatPrice(motor.price)}
|
||||
</div>
|
||||
</div>
|
||||
{motor.links && motor.links.length > 0 && (
|
||||
<div className={`${isSlightlyLarger ? 'mt-3' : 'mt-2'} pt-3 border-t border-gray-200 dark:border-gray-700`}>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Buy from:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{motor.links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:text-blue-800 dark:hover:text-blue-200 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3 mr-1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
{link.store}
|
||||
</a>
|
||||
))}
|
||||
<>
|
||||
<div className={`${isSlightlyLarger ? 'mb-3' : 'mb-2'} flex items-center justify-between`}>
|
||||
<div className={`${isSlightlyLarger ? 'text-lg' : 'text-lg'} font-bold text-blue-600 dark:text-blue-400`}>
|
||||
{motorPrices[motor.id] || '...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${isSlightlyLarger ? 'mt-4' : 'mt-3'} pt-3 border-t border-gray-200 dark:border-gray-700`}>
|
||||
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Buy from:</p>
|
||||
<div className="space-y-2">
|
||||
{motor.links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-2.5 rounded-md border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||
{link.store}
|
||||
</span>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{link.link}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
{link.price != null && (
|
||||
<AsyncPrice
|
||||
price={link.price}
|
||||
className="text-sm font-bold text-blue-600 dark:text-blue-400 whitespace-nowrap"
|
||||
fallback="..."
|
||||
/>
|
||||
)}
|
||||
{link.updated && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||
Updated {new Date(link.updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -129,16 +172,16 @@ export default function MotorStep({ config, updateConfig }) {
|
||||
|
||||
{/* Recommended Motor(s) */}
|
||||
{recommendedMotors.length > 0 && (
|
||||
<div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center' : ''}`}>
|
||||
<div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center w-full' : ''}`}>
|
||||
{hasSingleRecommended ? (
|
||||
<div className="w-full max-w-md">
|
||||
{renderMotorCard(recommendedMotors[0], true, true)}
|
||||
{renderMotorCard(recommendedMotors[0], true)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-700 dark:text-gray-300">Recommended Options</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{recommendedMotors.map((motor) => renderMotorCard(motor, true, false))}
|
||||
{recommendedMotors.map((motor) => renderMotorCard(motor, false))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -150,10 +193,19 @@ export default function MotorStep({ config, updateConfig }) {
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-700 dark:text-gray-300">Other Options</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{otherMotors.map((motor) => renderMotorCard(motor, false, false))}
|
||||
{otherMotors.map((motor) => renderMotorCard(motor, false))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MotorStep.propTypes = {
|
||||
config: PropTypes.shape({
|
||||
motor: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
}),
|
||||
}).isRequired,
|
||||
updateConfig: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
import ImageWithFallback from '../ui/ImageWithFallback';
|
||||
|
||||
export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
const [expandedMainSections, setExpandedMainSections] = useState({});
|
||||
@@ -34,7 +35,7 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
const handleStandCrossbarSupportToggle = (option) => {
|
||||
const currentSupports = config.standCrossbarSupports || [];
|
||||
const isSelected = currentSupports.some((opt) => opt.id === option.id);
|
||||
|
||||
|
||||
if (isSelected) {
|
||||
updateConfig({
|
||||
standCrossbarSupports: currentSupports.filter((opt) => opt.id !== option.id),
|
||||
@@ -62,7 +63,7 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
|
||||
const getSelectedOptionsForSubSection = (mainSectionId, subSectionId, subSection = null) => {
|
||||
const key = `${mainSectionId}.${subSectionId}`;
|
||||
|
||||
|
||||
switch (key) {
|
||||
case 'actuator.mounts':
|
||||
return config.mount ? [config.mount] : [];
|
||||
@@ -88,36 +89,36 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
|
||||
const isMainSectionComplete = (mainSectionId, mainSection) => {
|
||||
const subSections = Object.entries(mainSection.sections || {});
|
||||
|
||||
|
||||
// Check if all sub-sections with options are complete
|
||||
for (const [subSectionId, subSection] of subSections) {
|
||||
// Skip if no options available
|
||||
if (!subSection.options || subSection.options.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
const selectedOptions = getSelectedOptionsForSubSection(mainSectionId, subSectionId, subSection);
|
||||
|
||||
|
||||
// All sub-sections (both single-select and multi-select) require at least one selection
|
||||
// Multi-select means you can select multiple items, but you still need at least one
|
||||
if (selectedOptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Auto-collapse main sections when they become complete
|
||||
useEffect(() => {
|
||||
const mainSections = partsData.options ? Object.entries(partsData.options) : [];
|
||||
|
||||
|
||||
mainSections.forEach(([mainSectionId, mainSection]) => {
|
||||
// Skip toyMounts and remoteControl sections (now in their own steps)
|
||||
if (mainSectionId === 'toyMounts' || mainSectionId === 'remoteControl') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isMainSectionComplete(mainSectionId, mainSection)) {
|
||||
setExpandedMainSections((prev) => {
|
||||
// Only auto-collapse if the section is currently expanded (undefined or true)
|
||||
@@ -135,7 +136,7 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
|
||||
const handleOptionClick = (option, mainSectionId, subSectionId) => {
|
||||
const key = `${mainSectionId}.${subSectionId}`;
|
||||
|
||||
|
||||
switch (key) {
|
||||
case 'actuator.mounts':
|
||||
handleMountSelect(option);
|
||||
@@ -167,24 +168,17 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => handleOptionClick(option, mainSectionId, subSectionId)}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
|
||||
isSelected
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${isSelected
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{option.image && (
|
||||
<div className="mb-3 flex justify-center">
|
||||
<img
|
||||
src={option.image}
|
||||
alt={option.name}
|
||||
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ImageWithFallback
|
||||
src={option.image}
|
||||
alt={option.name}
|
||||
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
containerClassName="mb-3 flex justify-center"
|
||||
/>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
@@ -274,9 +268,8 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
|
||||
isExpanded ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${isExpanded ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -308,21 +301,18 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
const isComplete = isMainSectionComplete(mainSectionId, mainSection);
|
||||
|
||||
return (
|
||||
<div key={mainSectionId} className={`border-2 rounded-lg overflow-hidden mb-4 ${
|
||||
isComplete ? 'border-green-500 dark:border-green-600' : 'border-gray-300 dark:border-gray-700'
|
||||
}`}>
|
||||
<div key={mainSectionId} className={`border-2 rounded-lg overflow-hidden mb-4 ${isComplete ? 'border-green-500 dark:border-green-600' : 'border-gray-300 dark:border-gray-700'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => toggleMainSection(mainSectionId)}
|
||||
className={`w-full px-6 py-4 transition-colors flex items-center justify-between ${
|
||||
isComplete
|
||||
? 'bg-green-50 dark:bg-green-900/30 hover:bg-green-100 dark:hover:bg-green-900/40'
|
||||
className={`w-full px-6 py-4 transition-colors flex items-center justify-between ${isComplete
|
||||
? 'bg-green-50 dark:bg-green-900/30 hover:bg-green-100 dark:hover:bg-green-900/40'
|
||||
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className={`text-xl font-bold ${
|
||||
isComplete ? 'text-green-900 dark:text-green-300' : 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
<h3 className={`text-xl font-bold ${isComplete ? 'text-green-900 dark:text-green-300' : 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{mainSection.title}
|
||||
</h3>
|
||||
{isComplete && (
|
||||
@@ -347,9 +337,8 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform ${
|
||||
isExpanded ? 'transform rotate-180' : ''
|
||||
} ${isComplete ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
className={`w-6 h-6 transition-transform ${isExpanded ? 'transform rotate-180' : ''
|
||||
} ${isComplete ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -388,9 +377,9 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
const filteredSubSections = {};
|
||||
Object.entries(mainSection.sections || {}).forEach(([subSectionId, subSection]) => {
|
||||
// Check if this sub-section has mod options
|
||||
const hasModOptions = subSection.options?.some(opt => opt.type === 'mod') ||
|
||||
subSection.componentType === 'mod';
|
||||
|
||||
const hasModOptions = subSection.options?.some(opt => opt.type === 'mod') ||
|
||||
subSection.componentType === 'mod';
|
||||
|
||||
if (hasModOptions) {
|
||||
// Filter options to only show mods
|
||||
const modOptions = subSection.options?.filter(opt => opt.type === 'mod') || [];
|
||||
@@ -423,7 +412,7 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
|
||||
{buildType === 'upgrade' ? 'Select Upgrades & Modifications' : 'Select Options'}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6">
|
||||
{buildType === 'upgrade'
|
||||
{buildType === 'upgrade'
|
||||
? 'Choose upgrade components and modifications for your existing build.'
|
||||
: 'Choose your preferred mounting options and accessories.'}
|
||||
</p>
|
||||
|
||||
@@ -1,19 +1,42 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import ImageWithFallback from '../ui/ImageWithFallback';
|
||||
import AsyncPrice from '../ui/AsyncPrice';
|
||||
|
||||
export default function PowerSupplyStep({ config, updateConfig }) {
|
||||
const selectedPowerSupplyId = config.powerSupply?.id;
|
||||
const selectedMotorId = config.motor?.id;
|
||||
const { currency, exchangeRates } = useCurrency();
|
||||
const [psuPrices, setPsuPrices] = useState({});
|
||||
|
||||
const handleSelect = (powerSupply) => {
|
||||
updateConfig({ powerSupply });
|
||||
};
|
||||
|
||||
// Filter compatible power supplies
|
||||
const compatiblePowerSupplies = partsData.powerSupplies.filter((psu) => {
|
||||
if (!selectedMotorId) return true;
|
||||
return psu.compatibleMotors.includes(selectedMotorId);
|
||||
});
|
||||
const compatiblePowerSupplies = useMemo(() => {
|
||||
return partsData.powerSupplies.filter((psu) => {
|
||||
if (!selectedMotorId) return true;
|
||||
return psu.compatibleMotors.includes(selectedMotorId);
|
||||
});
|
||||
}, [selectedMotorId]);
|
||||
|
||||
useEffect(() => {
|
||||
const updatePrices = async () => {
|
||||
if (!exchangeRates) return;
|
||||
const prices = {};
|
||||
for (const psu of compatiblePowerSupplies) {
|
||||
if (psu.links && psu.links.length > 0) {
|
||||
const price = await getPriceDisplayFromLinksAsync(psu, currency, exchangeRates);
|
||||
prices[psu.id] = price;
|
||||
}
|
||||
}
|
||||
setPsuPrices(prices);
|
||||
};
|
||||
updatePrices();
|
||||
}, [currency, exchangeRates, compatiblePowerSupplies]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -38,24 +61,17 @@ export default function PowerSupplyStep({ config, updateConfig }) {
|
||||
<button
|
||||
key={powerSupply.id}
|
||||
onClick={() => handleSelect(powerSupply)}
|
||||
className={`p-6 border-2 rounded-lg text-left transition-all ${
|
||||
selectedPowerSupplyId === powerSupply.id
|
||||
className={`w-full p-6 border-2 rounded-lg text-left transition-all ${selectedPowerSupplyId === powerSupply.id
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{powerSupply.image && (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img
|
||||
src={powerSupply.image}
|
||||
alt={powerSupply.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ImageWithFallback
|
||||
src={powerSupply.image}
|
||||
alt={powerSupply.name}
|
||||
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
containerClassName="mb-4 flex justify-center"
|
||||
/>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{powerSupply.name}
|
||||
@@ -81,7 +97,7 @@ export default function PowerSupplyStep({ config, updateConfig }) {
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
{powerSupply.description}
|
||||
</p>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="flex gap-4 text-sm mb-3">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">Voltage:</span>{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{powerSupply.voltage}</span>
|
||||
@@ -91,42 +107,69 @@ export default function PowerSupplyStep({ config, updateConfig }) {
|
||||
<span className="font-medium text-gray-900 dark:text-white">{powerSupply.current}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{formatPrice(powerSupply.price)}
|
||||
</div>
|
||||
</div>
|
||||
{powerSupply.links && powerSupply.links.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Buy from:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{powerSupply.links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:text-blue-800 dark:hover:text-blue-200 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3 mr-1.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
{link.store}
|
||||
</a>
|
||||
))}
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">
|
||||
{psuPrices[powerSupply.id] || '...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-3 uppercase tracking-wide">Buy from:</p>
|
||||
<div className="space-y-2">
|
||||
{powerSupply.links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block p-2.5 rounded-md border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||
{link.store}
|
||||
</span>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{link.link}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
{link.price != null && (
|
||||
<AsyncPrice
|
||||
price={link.price}
|
||||
className="text-sm font-bold text-blue-600 dark:text-blue-400 whitespace-nowrap"
|
||||
fallback="..."
|
||||
/>
|
||||
)}
|
||||
{link.updated && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
|
||||
Updated {new Date(link.updated).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
import ImageWithFallback from '../ui/ImageWithFallback';
|
||||
|
||||
export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
const [expandedKnobs, setExpandedKnobs] = useState(false);
|
||||
@@ -33,7 +34,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
const updates = {
|
||||
remoteType: remoteId,
|
||||
};
|
||||
|
||||
|
||||
// Reset PCB selection when switching remotes
|
||||
if (remoteId === 'ossm-remote-radr') {
|
||||
// RADR only available from RAD
|
||||
@@ -41,10 +42,10 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
} else {
|
||||
updates.remotePCB = null;
|
||||
}
|
||||
|
||||
|
||||
// Clear knob selection when switching remotes
|
||||
updates.remoteKnob = null;
|
||||
|
||||
|
||||
updateConfig(updates);
|
||||
};
|
||||
|
||||
@@ -64,7 +65,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
const getAvailableKnobs = () => {
|
||||
const remoteSystem = getSelectedRemoteSystem();
|
||||
if (!remoteSystem || !remoteSystem.knobs) return [];
|
||||
|
||||
|
||||
return remoteSystem.knobs.map((knob) => ({
|
||||
id: knob.id,
|
||||
name: knob.name,
|
||||
@@ -88,24 +89,17 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
<button
|
||||
key={remote.id}
|
||||
onClick={() => handleRemoteSelect(remote.id)}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
|
||||
isSelected
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${isSelected
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{imagePath && (
|
||||
<div className="mb-3 flex justify-center">
|
||||
<img
|
||||
src={imagePath}
|
||||
alt={remote.name}
|
||||
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ImageWithFallback
|
||||
src={imagePath}
|
||||
alt={remote.name}
|
||||
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
containerClassName="mb-3 flex justify-center"
|
||||
/>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
@@ -147,21 +141,19 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => handlePCBSelect('rad')}
|
||||
className={`px-4 py-2 border-2 rounded-lg transition-all ${
|
||||
selectedRemotePCB === 'rad'
|
||||
className={`px-4 py-2 border-2 rounded-lg transition-all ${selectedRemotePCB === 'rad'
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-900 dark:text-blue-300 font-medium'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
Purchase from RAD
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePCBSelect('pcbway')}
|
||||
className={`px-4 py-2 border-2 rounded-lg transition-all ${
|
||||
selectedRemotePCB === 'pcbway'
|
||||
className={`px-4 py-2 border-2 rounded-lg transition-all ${selectedRemotePCB === 'pcbway'
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-900 dark:text-blue-300 font-medium'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
Self-source with PCBWay
|
||||
</button>
|
||||
@@ -177,11 +169,10 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
<button
|
||||
key={knob.id}
|
||||
onClick={() => handleKnobSelect(knob)}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${
|
||||
isSelected
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${isSelected
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
@@ -243,7 +234,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
|
||||
{/* Remote Selection */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">Remote System</h3>
|
||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">Remote System</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{availableRemotesFiltered.map((remote) => renderRemoteCard(remote))}
|
||||
</div>
|
||||
@@ -285,9 +276,8 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${
|
||||
expandedKnobs ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${expandedKnobs ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import partsData from '../../data/index.js';
|
||||
import { formatPrice } from '../../utils/priceFormat';
|
||||
import ImageWithFallback from '../ui/ImageWithFallback';
|
||||
|
||||
export default function ToyMountStep({ config, updateConfig }) {
|
||||
const [expandedSubSections, setExpandedSubSections] = useState({});
|
||||
@@ -56,18 +57,13 @@ export default function ToyMountStep({ config, updateConfig }) {
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{option.image && (
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={option.image}
|
||||
alt={option.name}
|
||||
className="h-24 w-24 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-shrink-0">
|
||||
<ImageWithFallback
|
||||
src={option.image}
|
||||
alt={option.name}
|
||||
className="h-24 w-24 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
|
||||
63
website/src/components/ui/AsyncPrice.jsx
Normal file
63
website/src/components/ui/AsyncPrice.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import { formatPriceWithConversion } from '../../utils/priceFormat';
|
||||
import { getPriceDisplayFromLinksAsync } from '../../utils/bomUtils';
|
||||
|
||||
/**
|
||||
* Component that displays a price with automatic currency conversion (async)
|
||||
* Handles both price objects and item objects with links (motor, PSU, PCB)
|
||||
*/
|
||||
export default function AsyncPrice({ price, className = '', fallback = '...' }) {
|
||||
const { currency, exchangeRates } = useCurrency();
|
||||
const [formattedPrice, setFormattedPrice] = useState(fallback);
|
||||
|
||||
useEffect(() => {
|
||||
if (!price && price !== 0) {
|
||||
setFormattedPrice('C$0.00');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePrice = async () => {
|
||||
try {
|
||||
// Check if this is an item object with links (like motor, PSU, PCB)
|
||||
if (typeof price === 'object' && price.links && Array.isArray(price.links) && price.links.length > 0) {
|
||||
// Use getPriceDisplayFromLinksAsync for items with links
|
||||
const formatted = await getPriceDisplayFromLinksAsync(price, currency, exchangeRates);
|
||||
setFormattedPrice(formatted);
|
||||
} else {
|
||||
// Use formatPriceWithConversion for price objects/numbers/strings
|
||||
const formatted = await formatPriceWithConversion(price, currency, exchangeRates);
|
||||
setFormattedPrice(formatted);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to format price:', error);
|
||||
// Fallback to basic formatting
|
||||
if (typeof price === 'number') {
|
||||
setFormattedPrice(`C$${price.toFixed(2)}`);
|
||||
} else if (typeof price === 'object' && price.amount) {
|
||||
const amount = typeof price.amount === 'object' ? price.amount.min : price.amount;
|
||||
setFormattedPrice(`C$${amount?.toFixed(2) || '0.00'}`);
|
||||
} else if (typeof price === 'string') {
|
||||
setFormattedPrice(price);
|
||||
} else {
|
||||
setFormattedPrice('C$0.00');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updatePrice();
|
||||
}, [price, currency, exchangeRates]);
|
||||
|
||||
return <span className={className}>{formattedPrice}</span>;
|
||||
}
|
||||
|
||||
AsyncPrice.propTypes = {
|
||||
price: PropTypes.oneOfType([
|
||||
PropTypes.number,
|
||||
PropTypes.string,
|
||||
PropTypes.object,
|
||||
]),
|
||||
className: PropTypes.string,
|
||||
fallback: PropTypes.string,
|
||||
};
|
||||
56
website/src/components/ui/DataTable.jsx
Normal file
56
website/src/components/ui/DataTable.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Reusable data table component
|
||||
*/
|
||||
export default function DataTable({
|
||||
columns,
|
||||
data,
|
||||
renderRow,
|
||||
className = '',
|
||||
emptyMessage = 'No data available'
|
||||
}) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`overflow-x-auto ${className}`}>
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`px-4 py-3 text-${column.align || 'left'} text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider`}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data.map((row, index) => renderRow(row, index))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DataTable.propTypes = {
|
||||
columns: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
key: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
align: PropTypes.oneOf(['left', 'right', 'center']),
|
||||
})
|
||||
).isRequired,
|
||||
data: PropTypes.array.isRequired,
|
||||
renderRow: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
emptyMessage: PropTypes.string,
|
||||
};
|
||||
204
website/src/components/ui/ExportButton.jsx
Normal file
204
website/src/components/ui/ExportButton.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import JSZip from 'jszip';
|
||||
import { createShareLink } from '../../utils/shareService';
|
||||
import { generateMarkdownOverview, generateExcelBOM, generateExcelPrintList } from '../../utils/exportUtils';
|
||||
|
||||
/**
|
||||
* Export button component with progress indicator
|
||||
*/
|
||||
export default function ExportButton({
|
||||
config,
|
||||
printedParts,
|
||||
hardwareParts,
|
||||
filamentTotals,
|
||||
totalTime,
|
||||
total
|
||||
}) {
|
||||
const [isExportingZip, setIsExportingZip] = useState(false);
|
||||
const [zipProgress, setZipProgress] = useState({ current: 0, total: 0, currentFile: '' });
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setIsExportingZip(true);
|
||||
setZipProgress({ current: 0, total: 0, currentFile: 'Preparing export...' });
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
// 1. Generate and add markdown overview
|
||||
setZipProgress({ current: 1, total: 100, currentFile: 'Generating overview...' });
|
||||
const markdownOverview = generateMarkdownOverview(
|
||||
config,
|
||||
printedParts,
|
||||
hardwareParts,
|
||||
filamentTotals,
|
||||
totalTime,
|
||||
total
|
||||
);
|
||||
zip.file('README.md', markdownOverview);
|
||||
|
||||
// 2. Generate and add Excel BOM
|
||||
setZipProgress({ current: 20, total: 100, currentFile: 'Generating BOM...' });
|
||||
const bomWorkbook = generateExcelBOM(hardwareParts, printedParts, config);
|
||||
const bomBuffer = await bomWorkbook.xlsx.writeBuffer();
|
||||
zip.file('BOM.xlsx', bomBuffer);
|
||||
|
||||
// 3. Generate and add Excel Print List
|
||||
setZipProgress({ current: 40, total: 100, currentFile: 'Generating print list...' });
|
||||
const printListWorkbook = generateExcelPrintList(printedParts, filamentTotals);
|
||||
const printListBuffer = await printListWorkbook.xlsx.writeBuffer();
|
||||
zip.file('Print_List.xlsx', printListBuffer);
|
||||
|
||||
// 4. Download and organize print files by component and colors
|
||||
setZipProgress({ current: 50, total: 100, currentFile: 'Organizing print files...' });
|
||||
const partsToDownload = printedParts.filter(part => part.url && !part.isHardwareOnly);
|
||||
|
||||
if (partsToDownload.length > 0) {
|
||||
// Convert GitHub blob URLs to raw.githubusercontent.com URLs
|
||||
const convertGitHubUrl = (url) => {
|
||||
if (!url) return url;
|
||||
const blobMatch = url.match(/https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/\?]+)\/(.+?)(\?raw=true)?$/);
|
||||
if (blobMatch) {
|
||||
const [, owner, repo, branch, encodedPath] = blobMatch;
|
||||
const decodedPath = decodeURIComponent(encodedPath);
|
||||
const baseUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/`;
|
||||
const urlObj = new URL(decodedPath, baseUrl);
|
||||
return urlObj.href;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
// Download files with concurrency limit
|
||||
const downloadFile = async (part, index) => {
|
||||
try {
|
||||
const progress = 50 + Math.floor((index / partsToDownload.length) * 40);
|
||||
setZipProgress({
|
||||
current: progress,
|
||||
total: 100,
|
||||
currentFile: `Downloading ${part.filePath || part.name}...`
|
||||
});
|
||||
|
||||
const rawUrl = convertGitHubUrl(part.url);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||
|
||||
const response = await fetch(rawUrl, { signal: controller.signal });
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download ${part.filePath}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
// Organize by component/color: Print_Files/Component/Color/filename
|
||||
const componentDir = part.category || 'Other';
|
||||
const colourDir = part.colour === 'primary' ? 'Primary' : part.colour === 'secondary' ? 'Accent' : 'Other';
|
||||
const filename = part.filePath || `${part.id}.stl`;
|
||||
const zipPath = `Print_Files/${componentDir}/${colourDir}/${filename}`;
|
||||
|
||||
zip.file(zipPath, arrayBuffer);
|
||||
return { success: true, part: part.filePath };
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.error(`Timeout downloading ${part.filePath}`);
|
||||
} else {
|
||||
console.error(`Error downloading ${part.filePath}:`, error);
|
||||
}
|
||||
return { success: false, part: part.filePath, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// Download files with concurrency limit (3 at a time)
|
||||
const concurrencyLimit = 3;
|
||||
const results = [];
|
||||
for (let i = 0; i < partsToDownload.length; i += concurrencyLimit) {
|
||||
const batch = partsToDownload.slice(i, i + concurrencyLimit);
|
||||
const batchPromises = batch.map((part, batchIndex) => downloadFile(part, i + batchIndex));
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const successful = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.warn(`Failed to download ${failed.length} file(s):`, failed.map(f => f.part));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Generate final zip
|
||||
setZipProgress({ current: 95, total: 100, currentFile: 'Creating ZIP file...' });
|
||||
const zipBlob = await zip.generateAsync({
|
||||
type: 'blob',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
});
|
||||
|
||||
// 6. Download
|
||||
setZipProgress({ current: 100, total: 100, currentFile: 'Complete!' });
|
||||
const url = URL.createObjectURL(zipBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ossm-build-export.zip';
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
a.click();
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 200);
|
||||
|
||||
setZipProgress({ current: 0, total: 0, currentFile: '' });
|
||||
setIsExportingZip(false);
|
||||
} catch (error) {
|
||||
console.error('Error creating export:', error);
|
||||
alert('Error creating export. Please try again.');
|
||||
setZipProgress({ current: 0, total: 0, currentFile: '' });
|
||||
setIsExportingZip(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExportingZip}
|
||||
className="w-full px-6 py-3 bg-green-600 dark:bg-green-500 text-white rounded-lg font-medium hover:bg-green-700 dark:hover:bg-green-600 transition-colors disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExportingZip ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<span>Exporting...</span>
|
||||
{zipProgress.total > 0 && (
|
||||
<div className="mt-2 w-full">
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>{zipProgress.current}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${zipProgress.current}%` }}
|
||||
/>
|
||||
</div>
|
||||
{zipProgress.currentFile && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">{zipProgress.currentFile}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
'Export All'
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
ExportButton.propTypes = {
|
||||
config: PropTypes.object.isRequired,
|
||||
printedParts: PropTypes.array.isRequired,
|
||||
hardwareParts: PropTypes.array.isRequired,
|
||||
filamentTotals: PropTypes.object.isRequired,
|
||||
totalTime: PropTypes.string.isRequired,
|
||||
total: PropTypes.number.isRequired,
|
||||
};
|
||||
76
website/src/components/ui/FilamentDisplay.jsx
Normal file
76
website/src/components/ui/FilamentDisplay.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Component for displaying filament usage information
|
||||
*/
|
||||
export default function FilamentDisplay({
|
||||
filamentTotals,
|
||||
totalTime,
|
||||
primaryColor,
|
||||
accentColor,
|
||||
getColorName,
|
||||
getColorHex
|
||||
}) {
|
||||
if (filamentTotals.total === 0 && totalTime === '0m') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4">
|
||||
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-white">Filament Usage</h3>
|
||||
<div className="space-y-2">
|
||||
{filamentTotals.total > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Total Filament:</span>
|
||||
<span className="font-bold text-gray-900 dark:text-white">{Math.round(filamentTotals.total)}g</span>
|
||||
</div>
|
||||
{filamentTotals.primary > 0 && getColorName && getColorHex && (
|
||||
<div className="flex justify-between items-center text-sm text-gray-600 dark:text-gray-400 ml-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: getColorHex(primaryColor, 'primary') }}
|
||||
/>
|
||||
<span>Primary ({getColorName(primaryColor, 'primary')}):</span>
|
||||
</div>
|
||||
<span>{Math.round(filamentTotals.primary)}g</span>
|
||||
</div>
|
||||
)}
|
||||
{filamentTotals.secondary > 0 && getColorName && getColorHex && (
|
||||
<div className="flex justify-between items-center text-sm text-gray-600 dark:text-gray-400 ml-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: getColorHex(accentColor, 'accent') }}
|
||||
/>
|
||||
<span>Secondary ({getColorName(accentColor, 'accent')}):</span>
|
||||
</div>
|
||||
<span>{Math.round(filamentTotals.secondary)}g</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{totalTime !== '0m' && (
|
||||
<div className="flex justify-between items-center pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Total Printing Time:</span>
|
||||
<span className="font-bold text-gray-900 dark:text-white">{totalTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FilamentDisplay.propTypes = {
|
||||
filamentTotals: PropTypes.shape({
|
||||
primary: PropTypes.number,
|
||||
secondary: PropTypes.number,
|
||||
total: PropTypes.number,
|
||||
}).isRequired,
|
||||
totalTime: PropTypes.string.isRequired,
|
||||
primaryColor: PropTypes.string,
|
||||
accentColor: PropTypes.string,
|
||||
getColorName: PropTypes.func,
|
||||
getColorHex: PropTypes.func,
|
||||
};
|
||||
40
website/src/components/ui/ImageWithFallback.jsx
Normal file
40
website/src/components/ui/ImageWithFallback.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Image component with error handling fallback
|
||||
*/
|
||||
export default function ImageWithFallback({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
containerClassName = '',
|
||||
onError
|
||||
}) {
|
||||
const handleError = (e) => {
|
||||
e.target.style.display = 'none';
|
||||
if (onError) {
|
||||
onError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ImageWithFallback.propTypes = {
|
||||
src: PropTypes.string,
|
||||
alt: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
containerClassName: PropTypes.string,
|
||||
onError: PropTypes.func,
|
||||
};
|
||||
83
website/src/components/ui/OptionCard.jsx
Normal file
83
website/src/components/ui/OptionCard.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ImageWithFallback from './ImageWithFallback';
|
||||
|
||||
/**
|
||||
* Reusable option card component for displaying selectable options
|
||||
*/
|
||||
export default function OptionCard({
|
||||
option,
|
||||
isSelected = false,
|
||||
isMultiSelect = false,
|
||||
onClick,
|
||||
showPrice = false,
|
||||
imageSize = 'h-32 w-32',
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex flex-col items-center p-4 border-2 rounded-lg transition-all ${isSelected
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
} ${className}`}
|
||||
>
|
||||
{option.image && (
|
||||
<ImageWithFallback
|
||||
src={option.image}
|
||||
alt={option.name}
|
||||
className={`${imageSize} object-contain rounded-lg bg-gray-100 dark:bg-gray-700 mb-2`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-center text-gray-700 dark:text-gray-300 font-medium">
|
||||
{option.name}
|
||||
</span>
|
||||
{option.description && (
|
||||
<span className="text-xs text-center text-gray-500 dark:text-gray-400 mt-1">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
{showPrice && option.price && (
|
||||
<span className="text-xs text-center text-gray-600 dark:text-gray-400 mt-1">
|
||||
{typeof option.price === 'string' ? option.price : `$${option.price}`}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="mt-2 w-6 h-6 bg-blue-600 dark:bg-blue-500 rounded-full flex items-center justify-center">
|
||||
{isMultiSelect ? (
|
||||
<span className="text-white text-sm font-bold">✓</span>
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
OptionCard.propTypes = {
|
||||
option: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
image: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
price: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
}).isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
isMultiSelect: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
showPrice: PropTypes.bool,
|
||||
imageSize: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
43
website/src/components/ui/PriceDisplay.jsx
Normal file
43
website/src/components/ui/PriceDisplay.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useCurrency } from '../../contexts/CurrencyContext';
|
||||
import { formatPriceWithConversion } from '../../utils/priceFormat';
|
||||
|
||||
/**
|
||||
* Component that displays a price with automatic currency conversion
|
||||
*/
|
||||
export default function PriceDisplay({ price, className = '' }) {
|
||||
const { currency, exchangeRates } = useCurrency();
|
||||
const [formattedPrice, setFormattedPrice] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!price) {
|
||||
setFormattedPrice('$0.00');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePrice = async () => {
|
||||
try {
|
||||
const formatted = await formatPriceWithConversion(price, currency, exchangeRates);
|
||||
setFormattedPrice(formatted);
|
||||
} catch (error) {
|
||||
console.warn('Failed to format price:', error);
|
||||
// Fallback to basic formatting
|
||||
setFormattedPrice(typeof price === 'number' ? `C$${price.toFixed(2)}` : String(price));
|
||||
}
|
||||
};
|
||||
|
||||
updatePrice();
|
||||
}, [price, currency, exchangeRates]);
|
||||
|
||||
return <span className={className}>{formattedPrice}</span>;
|
||||
}
|
||||
|
||||
PriceDisplay.propTypes = {
|
||||
price: PropTypes.oneOfType([
|
||||
PropTypes.number,
|
||||
PropTypes.string,
|
||||
PropTypes.object,
|
||||
]),
|
||||
className: PropTypes.string,
|
||||
};
|
||||
40
website/src/components/ui/TabNavigation.jsx
Normal file
40
website/src/components/ui/TabNavigation.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
/**
|
||||
* Reusable tab navigation component
|
||||
*/
|
||||
export default function TabNavigation({ tabs, activeTab, onTabChange, className = '' }) {
|
||||
return (
|
||||
<div className={`border-b border-gray-200 dark:border-gray-700 mb-6 ${className}`}>
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-blue-500 dark:border-blue-400 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TabNavigation.propTypes = {
|
||||
tabs: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
activeTab: PropTypes.string.isRequired,
|
||||
onTabChange: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
7
website/src/components/ui/index.js
Normal file
7
website/src/components/ui/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Reusable UI Components
|
||||
export { default as ImageWithFallback } from './ImageWithFallback';
|
||||
export { default as OptionCard } from './OptionCard';
|
||||
export { default as TabNavigation } from './TabNavigation';
|
||||
export { default as DataTable } from './DataTable';
|
||||
export { default as FilamentDisplay } from './FilamentDisplay';
|
||||
export { default as ExportButton } from './ExportButton';
|
||||
84
website/src/contexts/CurrencyContext.jsx
Normal file
84
website/src/contexts/CurrencyContext.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const CurrencyContext = createContext();
|
||||
|
||||
export const useCurrency = () => {
|
||||
const context = useContext(CurrencyContext);
|
||||
if (!context) {
|
||||
throw new Error('useCurrency must be used within a CurrencyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const CurrencyProvider = ({ children }) => {
|
||||
const [currency, setCurrency] = useState(() => {
|
||||
// Check localStorage first
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedCurrency = localStorage.getItem('currency');
|
||||
if (savedCurrency) {
|
||||
return savedCurrency;
|
||||
}
|
||||
// Try to detect currency from browser locale
|
||||
const locale = navigator.language || navigator.userLanguage;
|
||||
if (locale.includes('en-CA') || locale.includes('fr-CA')) {
|
||||
return 'CAD';
|
||||
}
|
||||
if (locale.includes('en-GB')) {
|
||||
return 'GBP';
|
||||
}
|
||||
if (locale.includes('en-AU')) {
|
||||
return 'AUD';
|
||||
}
|
||||
if (locale.includes('eu') || locale.includes('de') || locale.includes('fr') || locale.includes('es') || locale.includes('it')) {
|
||||
return 'EUR';
|
||||
}
|
||||
if (locale.includes('ja') || locale.includes('JP')) {
|
||||
return 'JPY';
|
||||
}
|
||||
if (locale.includes('zh') || locale.includes('CN')) {
|
||||
return 'CNY';
|
||||
}
|
||||
}
|
||||
return 'CAD'; // Default to CAD
|
||||
});
|
||||
|
||||
const [exchangeRates, setExchangeRates] = useState(null);
|
||||
|
||||
// Preload exchange rates on mount
|
||||
useEffect(() => {
|
||||
import('../utils/currencyService').then(({ getExchangeRates }) => {
|
||||
getExchangeRates().then(rates => {
|
||||
setExchangeRates(rates);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Update exchange rates when currency changes
|
||||
useEffect(() => {
|
||||
if (currency && typeof window !== 'undefined') {
|
||||
import('../utils/currencyService').then(({ getExchangeRates }) => {
|
||||
getExchangeRates().then(rates => {
|
||||
setExchangeRates(rates);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [currency]);
|
||||
|
||||
useEffect(() => {
|
||||
// Save to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('currency', currency);
|
||||
}
|
||||
}, [currency]);
|
||||
|
||||
const setCurrencyWithSave = (newCurrency) => {
|
||||
setCurrency(newCurrency);
|
||||
localStorage.setItem('currency', newCurrency);
|
||||
};
|
||||
|
||||
return (
|
||||
<CurrencyContext.Provider value={{ currency, setCurrency: setCurrencyWithSave, exchangeRates }}>
|
||||
{children}
|
||||
</CurrencyContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -4,79 +4,172 @@
|
||||
"id": "hardware-fasteners-m3x8-shcs",
|
||||
"name": "M3x8 SHCS",
|
||||
"description": "Hardware fasteners m3x8 socket head cap screw",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M3x16 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m3x16-shcs",
|
||||
"name": "M3x16 SHCS",
|
||||
"description": "Hardware fasteners m3x16 socket head cap screw",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M3x20 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m3x20-shcs",
|
||||
"name": "M3x20 SHCS",
|
||||
"description": "m3x20 socket head cap screw",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M3 Hex Nut": {
|
||||
"id": "hardware-fasteners-m3-hex-nut",
|
||||
"name": "M3 Hex Nut",
|
||||
"description": "Hardware fasteners m3 hex nut",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M5 Hex Nut": {
|
||||
"id": "hardware-fasteners-m5-hex-nut",
|
||||
"name": "M5 Hex Nut",
|
||||
"description": "Hardware fasteners m5 hex nut",
|
||||
"price": 0
|
||||
"M4x10 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m4x10-shcs",
|
||||
"name": "M4x10 SHCS",
|
||||
"description": "Hardware fasteners m4x10 socket head cap screw",
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M4x12 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m4x12-shcs",
|
||||
"name": "M4x12 SHCS",
|
||||
"description": "Hardware fasteners m4x12 socket head cap screw",
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M4x25 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m4x25-shcs",
|
||||
"name": "M4x25 SHCS",
|
||||
"description": "Hardware fasteners m4x25 socket head cap screw",
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M4 Hex Nuts": {
|
||||
"id": "hardware-fasteners-m4-hex-nuts",
|
||||
"name": "M4 Hex Nuts",
|
||||
"description": "Hardware fasteners m4 hex nuts",
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M5 Hex Nuts": {
|
||||
"id": "hardware-fasteners-m5-hex-nuts",
|
||||
"name": "M5 Hex Nuts",
|
||||
"description": "Hardware fasteners m5 hex nuts",
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M5x20 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m5x20-shcs",
|
||||
"name": "M5x20 SHCS",
|
||||
"description": "Hardware fasteners m5x20 socket head cap screw",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M5x35 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m5x35-shcs",
|
||||
"name": "M5x35 SHCS",
|
||||
"description": "Hardware fasteners m5x35 socket head cap screw",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M5x40 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m5x40-shcs",
|
||||
"name": "M5x40 SHCS",
|
||||
"description": "Hardware fasteners m5x40 socket head cap screw",
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M5x20mm Hex Coupling Nut": {
|
||||
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
|
||||
"name": "M5x20mm Hex Coupling Nut",
|
||||
"description": "Hardware fasteners m5x20mm hex coupling nut",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M6x12 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m6x12-shcs",
|
||||
"name": "M6x12 SHCS",
|
||||
"description": "Hardware fasteners m6x12 socket head cap screw",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M6x20mm Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m6x20mm-shcs",
|
||||
"name": "M6x20mm SHCS",
|
||||
"description": "Hardware fasteners m6x20mm socket head cap screw",
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M6x25 Socket Head cap Screw": {
|
||||
"id": "hardware-fasteners-m6x25-shcs",
|
||||
"name": "M6x25 SHCS",
|
||||
"description": "Hardware fasteners m6x25 socket head cap screw",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M6 T Nuts": {
|
||||
"id": "hardware-fasteners-m6-t-nuts",
|
||||
"name": "M6 T Nuts",
|
||||
"description": "Hardware fasteners m6 t nuts",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M6 Washer": {
|
||||
"id": "hardware-fasteners-m6-washer",
|
||||
"name": "M6 Washer",
|
||||
"description": "Hardware fasteners m6 washer",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"M6x25 Handle": {
|
||||
"id": "hardware-fasteners-m6x25-handle",
|
||||
"name": "M6x25 Handle",
|
||||
"description": "Hardware fasteners m6x25 handle",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"motionComponents": {
|
||||
@@ -84,25 +177,37 @@
|
||||
"id": "hardware-gt2-pulley",
|
||||
"name": "GT2 Pulley",
|
||||
"description": "8mm Bore, 20T, 10mm Wide",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"GT2 Belt": {
|
||||
"id": "hardware-gt2-belt",
|
||||
"name": "GT2 Belt",
|
||||
"description": "10mm wide, 500mm long",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"MGN12H Linear Rail": {
|
||||
"id": "hardware-mgn12h-linear-rail",
|
||||
"name": "MGN12H Linear Rail",
|
||||
"description": "MGN12H Linear Rail, 350mm long [Min 250mm, recommended 350mm, Max 550mm]",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"Bearing MR115-2RS": {
|
||||
"id": "hardware-bearing-MR115-2RS 5x11x4mm",
|
||||
"name": "Bearing MR115-2RS 5x11x4mm",
|
||||
"description": "MR115-2RS 5x11x4mm",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extrusions": {
|
||||
@@ -110,7 +215,10 @@
|
||||
"id": "hardware-fasteners-3030-90-degree-support",
|
||||
"name": "3030 90 Degree Support",
|
||||
"description": "Hardware fasteners 3030 90 degree support",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -118,31 +226,46 @@
|
||||
"id": "remote-hardware",
|
||||
"name": "Remote Hardware",
|
||||
"description": "Remote hardware",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"PitClamp Hardware": {
|
||||
"id": "pitclamp-hardware",
|
||||
"name": "PitClamp Hardware",
|
||||
"description": "PitClamp hardware",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"PitClamp Reinforced 3030 Hardware": {
|
||||
"id": "pitclamp-reinforced-3030-hardware",
|
||||
"name": "PitClamp Reinforced 3030 Hardware",
|
||||
"description": "Hardware for PitClamp Reinforced 3030 hinges",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"Middle Pivot Hardware": {
|
||||
"id": "middle-pivot-hardware",
|
||||
"name": "Middle Pivot Hardware",
|
||||
"description": "Middle Pivot hardware",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
},
|
||||
"Toy Mount Hardware": {
|
||||
"id": "toy-mount-hardware",
|
||||
"name": "Toy Mount Hardware",
|
||||
"description": "Toy mount hardware",
|
||||
"price": 0
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,18 @@
|
||||
"timeEstimate": "2h14m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"filePath": "OSSM - Actuator Body Bottom.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Bottom.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Bottom.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-bottom",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Bottom.stl",
|
||||
"checksum_sha256": "e7abdb99a7e9b9e7408a7b04a7dd50e42cc74510ea2969016a45a2a1387dcde3",
|
||||
"last_checked": "2026-01-07T23:40:57.300803+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-middle",
|
||||
@@ -22,8 +32,18 @@
|
||||
"timeEstimate": "2h23m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"filePath": "OSSM - Actuator Body Middle.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Middle.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Middle.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-middle",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Middle.stl",
|
||||
"checksum_sha256": "ce6fb769378636c287af788ce42bdab1f2185dcffba929a0c72598742793b48a",
|
||||
"last_checked": "2026-01-07T23:40:59.324441+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-actuator-body-cover",
|
||||
@@ -33,8 +53,21 @@
|
||||
"timeEstimate": "1h3m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"Condition": {
|
||||
"cover.id": "standard-cover"
|
||||
},
|
||||
"filePath": "OSSM - Actuator Body Cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Cover.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Actuator%20-%20Body%20-%20Cover.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-cover",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Actuator - Body - Cover.stl",
|
||||
"checksum_sha256": "bbabc742d2f1753d1b4e21e42c197aec31a4a083b5c634e6e825cec69d4e3258",
|
||||
"last_checked": "2026-01-07T23:40:58.302462+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-belt-tensioner",
|
||||
@@ -44,8 +77,18 @@
|
||||
"timeEstimate": "40m25s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"filePath": "OSSM - Belt Tensioner.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Belt%20Tensioner.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%20Belt%20Tensioner.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-belt-tensioner",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - Belt Tensioner.stl",
|
||||
"checksum_sha256": "31c74250c237763b0013ff42cc714ce14c293382a726de363f1686a7559f525f",
|
||||
"last_checked": "2026-01-07T23:41:01.332754+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
@@ -55,8 +98,18 @@
|
||||
"timeEstimate": "19m36s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"filePath": "OSSM - 24mm Clamping Thread Belt Clamp.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20Belt%20Clamp.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20Belt%20Clamp.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-24mm-clamping-thread-belt-clamp",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - Belt Clamp.stl",
|
||||
"checksum_sha256": "457a71bc09cb53f12026fd829bec8fa5b04fdead0788822935780f42c90b9a7a",
|
||||
"last_checked": "2026-01-07T23:40:53.289981+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-clamping-thread-end-effector",
|
||||
@@ -66,8 +119,18 @@
|
||||
"timeEstimate": "1h20m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"filePath": "OSSM - 24mm Clamping Thread End Effector.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20End%20Effector.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Clamping%20Thread%20-%20End%20Effector.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-24mm-clamping-thread-end-effector",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Clamping Thread - End Effector.stl",
|
||||
"checksum_sha256": "4860947b201e2e773b295d33bba09423ae40b4adeef3605d62687f2d40277de1",
|
||||
"last_checked": "2026-01-07T23:40:54.274313+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-24mm-nut-5-sided",
|
||||
@@ -77,8 +140,18 @@
|
||||
"timeEstimate": "21m10s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - 24mm Nut 5 Sided.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Nut%20-%205%20Sided.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/OSSM%20-%2024mm%20Nut%20-%205%20Sided.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-24mm-nut-5-sided",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Actuator/OSSM - 24mm Nut - 5 Sided.stl",
|
||||
"checksum_sha256": "38630c70b2fb929bba9a705dabf5bbd7b49ec882963e042b7108dc74284dd6ff",
|
||||
"last_checked": "2026-01-07T23:40:55.286530+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
@@ -87,7 +160,8 @@
|
||||
"required": true,
|
||||
"quantity": 8,
|
||||
"relatedParts": [
|
||||
"ossm-actuator-body-bottom"
|
||||
"ossm-actuator-body-bottom",
|
||||
"ossm-actuator-body-cover"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -95,7 +169,7 @@
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-6-sided"
|
||||
"ossm-24mm-clamping-thread-end-effector"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -111,11 +185,11 @@
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-hex"
|
||||
"ossm-24mm-nut-5-sided"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5-hex-nut",
|
||||
"id": "hardware-fasteners-m5-hex-nuts",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
@@ -128,24 +202,15 @@
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-actuator-body-bottom",
|
||||
"ossm-actuator-body-middle",
|
||||
"ossm-actuator-body-middle-pivot"
|
||||
"ossm-actuator-body-middle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x35-shcs",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-shcs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
|
||||
"required": true,
|
||||
"quantity": 7,
|
||||
"relatedParts": [
|
||||
"ossm-24mm-nut-hex"
|
||||
"ossm-actuator-body-middle-pivot"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -170,8 +235,10 @@
|
||||
"required": true,
|
||||
"quantity": 1,
|
||||
"relatedParts": [
|
||||
"ossm-gt2-belt-clamp",
|
||||
"ossm-24mm-nut-shcs",
|
||||
"ossm-24mm-clamping-thread-belt-clamp",
|
||||
"ossm-24mm-nut-5-sided",
|
||||
"ossm-belt-tensioner",
|
||||
"ossm-24mm-clamping-thread-end-effector",
|
||||
"ossm-actuator-body-bottom"
|
||||
]
|
||||
},
|
||||
|
||||
80
website/src/data/components/motors.json
Normal file
80
website/src/data/components/motors.json
Normal file
@@ -0,0 +1,80 @@
|
||||
[
|
||||
{
|
||||
"id": "57AIM30",
|
||||
"name": "57AIM30 \"Gold Motor\"",
|
||||
"description": "This servo motor is specially designed for compact robotics applications with higher torque and lower speed than a traditional brushless servo.",
|
||||
"speed": "1500 RPM",
|
||||
"wattage": "100W",
|
||||
"gear_count": "RS485",
|
||||
"image": "/images/motors/57AIM30.png",
|
||||
"required": true,
|
||||
"recommended": true,
|
||||
"links": [
|
||||
{
|
||||
"store": "Research & Desire",
|
||||
"link": "https://www.researchanddesire.com/products/ossm-motor-gold-motor",
|
||||
"price": {
|
||||
"amount": {
|
||||
"min": 206.96,
|
||||
"max": 234.00
|
||||
},
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
},
|
||||
{
|
||||
"store": "AliExpress",
|
||||
"link": "https://www.aliexpress.com/item/1005008561507369.html",
|
||||
"price": {
|
||||
"amount": 125.38,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "42AIM",
|
||||
"name": "42AIM \"Round Motor\"",
|
||||
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
|
||||
"speed": "1500 RPM",
|
||||
"wattage": "100W",
|
||||
"gear_count": "RS485",
|
||||
"image": "/images/motors/42AIM30.png",
|
||||
"required": true,
|
||||
"recommended": false,
|
||||
"links": [
|
||||
{
|
||||
"store": "AliExpress",
|
||||
"link": "https://www.aliexpress.com/item/1005009689441933.html",
|
||||
"price": {
|
||||
"amount": 142.38,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "iHSV57",
|
||||
"name": "iHSV57 \"Legacy Motor\"",
|
||||
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
|
||||
"speed": "3000 RPM",
|
||||
"wattage": "180W",
|
||||
"gear_count": "RS485",
|
||||
"image": "/images/motors/iHSV57.png",
|
||||
"required": true,
|
||||
"recommended": false,
|
||||
"links": [
|
||||
{
|
||||
"store": "AliExpress",
|
||||
"link": "https://www.aliexpress.com/item/1005009473450253.html",
|
||||
"price": {
|
||||
"amount": 179.38,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"pitClamp": {
|
||||
"category": "PitClamp",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-lower",
|
||||
"name": "PitClamp Mini Lower",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 49.45,
|
||||
"timeEstimate": "1h55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Lower V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Lower%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-upper",
|
||||
"name": "PitClamp Mini Upper",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 27.36,
|
||||
"timeEstimate": "1h11m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Upper V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Upper%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-57AIM30",
|
||||
"name": "PitClamp Mini 57AIM30",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 46.03,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - 57AIM30 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2057AIM%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-42AIM30",
|
||||
"name": "PitClamp Mini 42AIM30",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 46.03,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - 42AIM30 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2042AIM%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-iHSV57",
|
||||
"name": "PitClamp Mini iHSV57",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 46.03,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - iHSV57 V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%20iHSV57.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-handle",
|
||||
"name": "PitClamp Mini Handle",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 9.23,
|
||||
"timeEstimate": "2h10m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Handle - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Handle.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-nuts",
|
||||
"name": "PitClamp Mini Dogbone Nuts",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 4.44,
|
||||
"timeEstimate": "20m49s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Dogbone Nuts - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Nuts.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pitclamp-mini-dogbone-bolts ",
|
||||
"name": "PitClamp Mini Dogbone Bolts",
|
||||
"description": "PitClamp mounting system",
|
||||
"filamentEstimate": 4.44,
|
||||
"timeEstimate": "20m49s",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Dogbone Bolts - PitClamp Mini V1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Bolts.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "pitclamp-hardware",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"middlePivot": {
|
||||
"category": "Middle Pivot",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-actuator-body-middle-pivot",
|
||||
"name": "Actuator Body Middle Pivot",
|
||||
"description": "Middle Pivot mounting system",
|
||||
"filamentEstimate": 147.19,
|
||||
"timeEstimate": "5h8m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Actuator Body Middle Pivot.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/Non-standard/OSSM%20-%20Actuator%20-%20Body%20-%20Middle%20Pivot.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-handle-spacer",
|
||||
"name": "Handle Spacer",
|
||||
"description": "Handle spacer part",
|
||||
"filamentEstimate": 0,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Handle Spacer.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "middle-pivot-hardware",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
website/src/data/components/mounting/index.js
Normal file
11
website/src/data/components/mounting/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import ossm from './ossm.json';
|
||||
export default {
|
||||
"mounting": {
|
||||
"category": "Mounting",
|
||||
"type": "base",
|
||||
"systems": {
|
||||
...ossm,
|
||||
// ...armpitmfg
|
||||
}
|
||||
}
|
||||
};
|
||||
240
website/src/data/components/mounting/ossm.json
Normal file
240
website/src/data/components/mounting/ossm.json
Normal file
@@ -0,0 +1,240 @@
|
||||
{
|
||||
"middle-pivot": {
|
||||
"name": "Middle Pivot",
|
||||
"description": "Standard OSSM Middle Pivot mounting system",
|
||||
"image": "/images/options/middle-pivot.png",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-actuator-body-middle-pivot",
|
||||
"name": "Actuator Body Middle Pivot",
|
||||
"description": "Middle Pivot mounting system",
|
||||
"filamentEstimate": 147.19,
|
||||
"timeEstimate": "5h8m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"replaces": [
|
||||
"ossm-actuator-body-middle"
|
||||
],
|
||||
"filePath": "OSSM - Actuator Body Middle Pivot.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Actuator/Non-standard/OSSM%20-%20Actuator%20-%20Body%20-%20Middle%20Pivot.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-actuator-body-middle-pivot",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"pinned_sha": "ad39a03b628b8e38549b99036c8dfd4131948545",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/ad39a03b628b8e38549b99036c8dfd4131948545/Printed Parts/Actuator/Non-standard/OSSM - Actuator - Body - Middle Pivot.stl",
|
||||
"checksum_sha256": "f6403a3c53e0d8c8e63d48bf853ab17c9f283421b1665b5503dbb04d59d0f52d",
|
||||
"last_checked": "2026-01-07T23:41:00.422074+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-handle-spacer",
|
||||
"name": "Handle Spacer",
|
||||
"description": "Handle spacer part",
|
||||
"filamentEstimate": 10,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"quantity": 2,
|
||||
"filePath": "OSSM - Handle Spacer.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-handle-spacer",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"checksum_sha256": "55ede7dff60a31d68159b352b5f2c63792b7a0dbe9d543a43681c3e52d229115",
|
||||
"last_checked": "2026-01-07T23:41:02.238918+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m5x35-shcs",
|
||||
"required": true,
|
||||
"quantity": 4
|
||||
}
|
||||
]
|
||||
},
|
||||
"pitclamp": {
|
||||
"name": "PitClamp Mini",
|
||||
"description": "armpitMFG PitClamp Mini - Modular Quick-Release Mount",
|
||||
"image": "https://raw.githubusercontent.com/armpitMFG/PitClamp-Mini/main/Images/Renders/Primary/Parts%20Only.png",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "pitclamp-mini-base-dogbone-bolts",
|
||||
"name": "PitClamp Mini Base - Dogbone Bolts",
|
||||
"description": "PitClamp Mini base part",
|
||||
"filamentEstimate": 10,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Dogbone Bolts.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Bolts.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-base-dogbone-nuts",
|
||||
"name": "PitClamp Mini Base - Dogbone Nuts",
|
||||
"description": "PitClamp Mini base part",
|
||||
"filamentEstimate": 10,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Dogbone Nuts.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Dogbone%20Nuts.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-base-dogbone-handle ",
|
||||
"name": "PitClamp Mini Base - Handle",
|
||||
"description": "PitClamp Mini base part",
|
||||
"filamentEstimate": 10,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Handle.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Handle.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-base-dogbone-lower",
|
||||
"name": "PitClamp Mini Base - Lower",
|
||||
"description": "PitClamp Mini base part",
|
||||
"filamentEstimate": 10,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Lower.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Lower%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-base-dogbone-upper",
|
||||
"name": "PitClamp Mini Base - Upper",
|
||||
"description": "PitClamp Mini base part",
|
||||
"filamentEstimate": 10,
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - Base - PitClamp Mini - Upper.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Base%20-%20PitClamp%20Mini%20-%20Upper%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-ring-57AIM",
|
||||
"name": "PitClamp Mini Ring - 57AIM",
|
||||
"description": "Motor ring for 57AIM",
|
||||
"filamentEstimate": 45,
|
||||
"timeEstimate": "1h45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"Condition": {
|
||||
"motor.id": "57AIM30"
|
||||
},
|
||||
"filePath": "OSSM - Mounting Ring - PitClamp Mini - 57AIM v1.1.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2057AIM%20V1.1.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-ring-ihsv57",
|
||||
"name": "PitClamp Mini Ring - iHSV57",
|
||||
"description": "Motor ring for iHSV57",
|
||||
"filamentEstimate": 45,
|
||||
"timeEstimate": "1h45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"Condition": {
|
||||
"motor.id": "iHSV57"
|
||||
},
|
||||
"filePath": "OSSM - Mounting Ring - PitClamp Mini - iHSV57.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%20iHSV57.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp-mini-ring-42AIM",
|
||||
"name": "PitClamp Mini Ring - 42AIM",
|
||||
"description": "Motor ring for 42AIM",
|
||||
"filamentEstimate": 45,
|
||||
"timeEstimate": "1h45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"Condition": {
|
||||
"motor.id": "42AIM"
|
||||
},
|
||||
"filePath": "OSSM - Mounting Ring - PitClamp Mini - 42AIM.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Mounting/Non-standard/OSSM%20-%20Mounting%20Ring%20-%20PitClamp%20Mini%20-%2042AIM%20V1.1.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m4x25-shcs",
|
||||
"required": true,
|
||||
"quantity": 2
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m4x12-shcs",
|
||||
"required": true,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m4x10-shcs",
|
||||
"required": true,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m4-hex-nuts",
|
||||
"required": true,
|
||||
"quantity": 3
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x20-shcs",
|
||||
"required": true,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5-hex-nuts",
|
||||
"required": true,
|
||||
"quantity": 1
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6x20mm-shcs",
|
||||
"required": true,
|
||||
"quantity": 2
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-t-nuts",
|
||||
"required": true,
|
||||
"quantity": 2
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x35-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"Condition": {
|
||||
"motor.id": "57AIM30"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5-hex-nuts",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"Condition": {
|
||||
"motor.id": "57AIM30"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5x40-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"Condition": {
|
||||
"motor.id": "iHSV57"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m5-hex-nuts",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"Condition": {
|
||||
"motor.id": "iHSV57"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m3x16-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"Condition": {
|
||||
"motor.id": "42AIM"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,21 @@
|
||||
{
|
||||
"3030-mount": {
|
||||
"category": "PCB Mount",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
[
|
||||
{
|
||||
"id": "ossm-v2-pcb",
|
||||
"name": "OSSM V2.3 PCB",
|
||||
"description": "Printed circuit board for OSSM v2.3. Features ESP32 microcontroller, sensorless homing (no limit switches needed), enhanced motor stability with large capacitor, over-voltage protection, 4-pin JST PH header for motor connections, and power monitoring with voltage/current sensing. Supports both stepper and servo-based configurations with 24V power input via 2.1mm barrel jack.",
|
||||
"image": "/images/pcb/ossm-v2-pcb.png",
|
||||
"required": true,
|
||||
"recommended": true,
|
||||
"links": [
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount",
|
||||
"name": "PCB 3030 Mount",
|
||||
"description": "PCB mount for 3030 extrusion",
|
||||
"filamentEstimate": 15,
|
||||
"timeEstimate": "45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - 3030 Mount.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount.stl?raw=true"
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount-cover",
|
||||
"name": "PCB 3030 Mount Cover",
|
||||
"description": "Cover for the 3030 mount",
|
||||
"filamentEstimate": 15,
|
||||
"timeEstimate": "45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount%20Cover.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m6x12-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"ossm-pcb-3030-mount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-t-nuts",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"ossm-pcb-3030-mount"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"aio-cover-mount": {
|
||||
"category": "PCB Mount",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-pcb-aio-cover-mount",
|
||||
"name": "PCB AIO Cover Mount",
|
||||
"description": "All-in-one cover mount on the actuator",
|
||||
"filamentEstimate": 20,
|
||||
"timeEstimate": "1h",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - AIO Cover Mount.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%20AIO%20Cover%20Mount.stl?raw=true"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m3x8-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"ossm-pcb-aio-cover-mount"
|
||||
]
|
||||
"store": "Research & Desire",
|
||||
"link": "https://www.researchanddesire.com/products/ossm-pcb-only",
|
||||
"price": {
|
||||
"amount": 83.20,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
56
website/src/data/components/pcb/armpitmfg.json
Normal file
56
website/src/data/components/pcb/armpitmfg.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"aio-cover-mount": {
|
||||
"name": "AIO Cover Mount",
|
||||
"description": "All-in-one cover mount for OSSM v2 AIO PCB",
|
||||
"image": "https://raw.githubusercontent.com/armpitMFG/OSSM-Parts/main/OSSM%20v2%20AIO%20PCB%20Backpack%20Mod/Images/Workspace/AIO%20PCB%20Backpack%20-%20Back%20Right.png",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-pcb-aio-backpack-base",
|
||||
"name": "AIO PCB Backpack Base",
|
||||
"description": "Base part for AIO PCB Backpack V2.3c",
|
||||
"filamentEstimate": 45,
|
||||
"timeEstimate": "2h",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM Mods - AIO PCB Backpack - Base V2.3c.stl",
|
||||
"url": "https://github.com/armpitMFG/OSSM-Parts/blob/main/OSSM%20v2%20AIO%20PCB%20Backpack%20Mod/Files/OSSM%20Mods%20-%20AIO%20PCB%20Backpack%20-%20Base%20V2.3c.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pcb-aio-backpack-base",
|
||||
"local_path": "vendor/armpitMFG-OSSM-Parts/OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Base V2.3c.stl",
|
||||
"pinned_sha": "9793908c23022a3a95651ac1f2287b056b84cd3d",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/armpitMFG/OSSM-Parts/9793908c23022a3a95651ac1f2287b056b84cd3d/OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Base V2.3c.stl",
|
||||
"checksum_sha256": "1fc1eb6947f02b6683ab5960adf6f0cb1ab4431d789dc64bca09e0d730d15990",
|
||||
"last_checked": "2026-01-07T23:41:08.621523+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-aio-backpack-cap",
|
||||
"name": "AIO PCB Backpack Cap",
|
||||
"description": "Default cap for AIO PCB Backpack",
|
||||
"filamentEstimate": 15,
|
||||
"timeEstimate": "45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM Mods - AIO PCB Backpack - Cap (Default).stl",
|
||||
"url": "https://github.com/armpitMFG/OSSM-Parts/blob/main/OSSM%20v2%20AIO%20PCB%20Backpack%20Mod/Files/OSSM%20Mods%20-%20AIO%20PCB%20Backpack%20-%20Cap%20(Default).stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pcb-aio-backpack-cap",
|
||||
"local_path": "vendor/armpitMFG-OSSM-Parts/OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Cap (Default).stl",
|
||||
"pinned_sha": "3f67f0834755e7b7e662acf0dbf2af0c257818b3",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/armpitMFG/OSSM-Parts/3f67f0834755e7b7e662acf0dbf2af0c257818b3/OSSM v2 AIO PCB Backpack Mod/Files/OSSM Mods - AIO PCB Backpack - Cap (Default).stl",
|
||||
"checksum_sha256": "dd76229dd480378df73eb101080c34978bb454017888004f64fc33f2c071e121",
|
||||
"last_checked": "2026-01-07T23:41:13.225270+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m3x8-shcs",
|
||||
"required": true,
|
||||
"quantity": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
website/src/data/components/pcb/index.js
Normal file
13
website/src/data/components/pcb/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import ossm from './ossm.json';
|
||||
import armpitmfg from './armpitmfg.json';
|
||||
|
||||
export default {
|
||||
"pcbMounts": {
|
||||
"category": "PCB Mount",
|
||||
"type": "base",
|
||||
"systems": {
|
||||
...ossm,
|
||||
...armpitmfg
|
||||
}
|
||||
}
|
||||
};
|
||||
69
website/src/data/components/pcb/ossm.json
Normal file
69
website/src/data/components/pcb/ossm.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"3030-mount": {
|
||||
"name": "3030 Mount",
|
||||
"description": "PCB mount for 3030 extrusion",
|
||||
"image": "/images/options/3030-pcb-mount.png",
|
||||
"category": "PCB Mount",
|
||||
"type": "base",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount",
|
||||
"name": "PCB 3030 Mount",
|
||||
"description": "PCB mount for 3030 extrusion",
|
||||
"filamentEstimate": 15,
|
||||
"timeEstimate": "45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - 3030 Mount.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pcb-3030-mount",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-pcb-3030-mount-cover",
|
||||
"name": "PCB 3030 Mount Cover",
|
||||
"description": "Cover for the 3030 mount",
|
||||
"filamentEstimate": 15,
|
||||
"timeEstimate": "45m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount%20Cover.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-pcb-3030-mount-cover",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/PCB/OSSM - PCB - 3030 Mount Cover.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "hardware-fasteners-m6x12-shcs",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"ossm-pcb-3030-mount"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hardware-fasteners-m6-t-nuts",
|
||||
"required": true,
|
||||
"quantity": 4,
|
||||
"relatedParts": [
|
||||
"ossm-pcb-3030-mount"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
88
website/src/data/components/powerSupplies.json
Normal file
88
website/src/data/components/powerSupplies.json
Normal file
@@ -0,0 +1,88 @@
|
||||
[
|
||||
{
|
||||
"id": "psu-24v-5a",
|
||||
"name": "24V 5A Power Supply",
|
||||
"description": "24V DC power supply, 5A output",
|
||||
"voltage": "24V",
|
||||
"current": "5A",
|
||||
"image": "/images/power-supplies/24v-PSU.png",
|
||||
"compatibleMotors": [
|
||||
"57AIM30",
|
||||
"42AIM",
|
||||
"iHSV57"
|
||||
],
|
||||
"required": true,
|
||||
"links": [
|
||||
{
|
||||
"store": "Amazon",
|
||||
"link": "https://a.co/d/6OZ6fwe",
|
||||
"price": {
|
||||
"amount": 25.96,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
},
|
||||
{
|
||||
"store": "AliExpress",
|
||||
"link": "https://www.aliexpress.com/item/1005005620894702.html",
|
||||
"price": {
|
||||
"amount": 15.96,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
},
|
||||
{
|
||||
"store": "Research & Desire",
|
||||
"link": "https://www.researchanddesire.com/products/ossm-24v-power-supply",
|
||||
"price": {
|
||||
"amount": 46.80,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "psu-24v-usbc-pd",
|
||||
"name": "24v USB-C PD Adapter",
|
||||
"description": "USB-C to 5.5x2.5mm 100w 12v Cable, Requires 100W+ Power Supply",
|
||||
"voltage": "24V",
|
||||
"current": "5A",
|
||||
"image": "/images/power-supplies/24v-usbc-pd.png",
|
||||
"compatibleMotors": [
|
||||
"57AIM30",
|
||||
"42AIM",
|
||||
"iHSV57"
|
||||
],
|
||||
"required": true,
|
||||
"links": [
|
||||
{
|
||||
"store": "Amazon",
|
||||
"link": "https://a.co/d/hIq5mRj",
|
||||
"price": {
|
||||
"amount": 15.99,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
},
|
||||
{
|
||||
"store": "AliExpress",
|
||||
"link": "https://www.aliexpress.com/item/1005003202359212.html",
|
||||
"price": {
|
||||
"amount": 1.62,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
},
|
||||
{
|
||||
"store": "Research & Desire",
|
||||
"link": "https://www.researchanddesire.com/products/ossm-24v-usb-c-adapter",
|
||||
"price": {
|
||||
"amount": 18.72,
|
||||
"currency": "CAD"
|
||||
},
|
||||
"updated": "2026-01-10"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -17,7 +17,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-body.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Body.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Body.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-body",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/OSSM - Remote - Body.stl",
|
||||
"checksum_sha256": "a0c3bb663a4bba6876a89c8e0dce81d0c6c673c9fc1f4537537000576f7f9e48",
|
||||
"last_checked": "2026-01-07T23:41:14.337483+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-top-cover",
|
||||
@@ -28,7 +37,16 @@
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-top-cover.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Top%20Cover.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Top%20Cover.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-top-cover",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/OSSM - Remote - Top Cover.stl",
|
||||
"checksum_sha256": "890a6b117dc6fd306b7523838ad81ae32ad4e642c90179a58d9f313b3f3c783e",
|
||||
"last_checked": "2026-01-07T23:41:26.173698+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"knobs": [
|
||||
@@ -41,7 +59,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-knob.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Knob%20-%20Rounded.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/OSSM%20-%20Remote%20-%20Knob%20-%20Rounded.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/OSSM - Remote - Knob - Rounded.stl",
|
||||
"checksum_sha256": "3dcf73220cecd534ea9db0d991cec1001b4495f0b9d98f71f5b0b8c68b780439",
|
||||
"last_checked": "2026-01-07T23:41:15.320622+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple",
|
||||
@@ -52,7 +79,16 @@
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-remote-knob-simple.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/tree/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/tree/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-simple",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
@@ -62,7 +98,16 @@
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-simple-with-position-indicator.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple%20With%20Position%20Indicator.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Simple%20With%20Position%20Indicator.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-simple-with-position-indicator",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Simple With Position Indicator.stl",
|
||||
"checksum_sha256": "b1816680cc49d5afe57b5d4f5dabab56b5446a429d43c94d184892365bfa9330",
|
||||
"last_checked": "2026-01-07T23:41:21.935568+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled",
|
||||
@@ -72,7 +117,16 @@
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-knurled.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-knurled",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled.stl",
|
||||
"checksum_sha256": "43f68e9467d4da82e6e9aaa545e9d9eda19ef60f8173ccd7fab7fa0835e770d4",
|
||||
"last_checked": "2026-01-07T23:41:16.349140+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
@@ -82,7 +136,16 @@
|
||||
"colour": "primary",
|
||||
"required": false,
|
||||
"filePath": "ossm-remote-knob-knurled-with-position-indicator.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled%20With%20Position%20Indicator.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Remote/Non-standard/OSSM%20-%20Remote%20-%20Knob%20-%20Knurled%20With%20Position%20Indicator.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-remote-knob-knurled-with-position-indicator",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Remote/Non-standard/OSSM - Remote - Knob - Knurled With Position Indicator.stl",
|
||||
"checksum_sha256": "4d06b58617b70633610c4d6fc8441eff45e751b904d7b0ecd442ea97a8bfa2a6",
|
||||
"last_checked": "2026-01-07T23:41:17.344170+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"description": "Pivot plate for the stand",
|
||||
"image": "/images/options/pivot-plate.webp",
|
||||
"hardwareCost": 10,
|
||||
"price": 0,
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
},
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "pivot-plate",
|
||||
@@ -19,7 +22,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Plate.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Left.stl?raw=true",
|
||||
"quantity": 1
|
||||
"quantity": 1,
|
||||
"vendor": {
|
||||
"manifest_id": "pivot-plate",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Left.stl",
|
||||
"checksum_sha256": "44a5527b613743acc394e4a6dfe89677d37c2e1c8a1537e76184812edbba0216",
|
||||
"last_checked": "2026-01-07T09:38:41.826563+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "pivot-plate-right",
|
||||
@@ -30,7 +42,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Plate.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Pivot%20Plate%20Right.stl?raw=true",
|
||||
"quantity": 1
|
||||
"quantity": 1,
|
||||
"vendor": {
|
||||
"manifest_id": "pivot-plate-right",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Pivot Plate Right.stl",
|
||||
"checksum_sha256": "20fba186fd8c50f08b35d57c354e62288d582ad283aa474ee271b27f08aa972a",
|
||||
"last_checked": "2026-01-07T10:37:58.707054+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "handle-spacer",
|
||||
@@ -41,7 +62,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - Stand - Pivot Spacer.stl",
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Handle%20Spacer.stl?raw=true",
|
||||
"quantity": 8
|
||||
"quantity": 8,
|
||||
"vendor": {
|
||||
"manifest_id": "handle-spacer",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Handle Spacer.stl",
|
||||
"checksum_sha256": "55ede7dff60a31d68159b352b5f2c63792b7a0dbe9d543a43681c3e52d229115",
|
||||
"last_checked": "2026-01-07T23:40:52.301339+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
@@ -72,7 +102,10 @@
|
||||
"description": "Reinforced 3030 hinges for PitClamp",
|
||||
"image": "/images/options/pitclamp-reinforced-3030-hinges.jpg",
|
||||
"hardwareCost": 15,
|
||||
"price": 0,
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
},
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "pitclamp-reinforced-3030",
|
||||
@@ -104,7 +137,10 @@
|
||||
"filamentEstimate": 50,
|
||||
"image": "/images/options/standard-feet.jpg",
|
||||
"hardwareCost": 0,
|
||||
"price": 0,
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
},
|
||||
"colour": "secondary",
|
||||
"required": true
|
||||
},
|
||||
@@ -115,7 +151,10 @@
|
||||
"filamentEstimate": 60,
|
||||
"image": "/images/options/suction-feet.jpg",
|
||||
"hardwareCost": 5,
|
||||
"price": 0,
|
||||
"price": {
|
||||
"amount": 0,
|
||||
"currency": "USD"
|
||||
},
|
||||
"colour": "secondary",
|
||||
"required": true
|
||||
}
|
||||
@@ -153,7 +192,16 @@
|
||||
"required": true,
|
||||
"filePath": "OSSM - 3030 Cap.stl",
|
||||
"quantity": 6,
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Extrusion%20Cap.stl?raw=true"
|
||||
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Stand/OSSM%20-%20Stand%20-%203030%20Extrusion%20Base%20-%20Extrusion%20Cap.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-3030-cap",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"pinned_sha": "52537c0896eaef83fd9771dcc633903c7aa6a8ab",
|
||||
"pinned_raw_url": "https://raw.githubusercontent.com/KinkyMakers/OSSM-hardware/52537c0896eaef83fd9771dcc633903c7aa6a8ab/Printed Parts/Stand/OSSM - Stand - 3030 Extrusion Base - Extrusion Cap.stl",
|
||||
"checksum_sha256": "56fa9bb318cdeadc6d1698a1e6cef9371e58b0bc9c7729985bf639d8da2f25da",
|
||||
"last_checked": "2026-01-07T23:40:56.344743+00:00",
|
||||
"status": "up-to-date"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -168,7 +216,13 @@
|
||||
"filamentEstimate": 0,
|
||||
"image": "/images/options/standard-90-degree-support.jpg",
|
||||
"hardwareCost": 10,
|
||||
"price": "$10.00-$20.00",
|
||||
"price": {
|
||||
"amount": {
|
||||
"min": 10.00,
|
||||
"max": 20.00
|
||||
},
|
||||
"currency": "USD"
|
||||
},
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"isHardwareOnly": true
|
||||
@@ -180,7 +234,13 @@
|
||||
"filamentEstimate": 100,
|
||||
"image": "/images/options/3d-printed-90-degree-support.jpg",
|
||||
"hardwareCost": 2,
|
||||
"price": "$2.00-$4.00",
|
||||
"price": {
|
||||
"amount": {
|
||||
"min": 2.00,
|
||||
"max": 4.00
|
||||
},
|
||||
"currency": "USD"
|
||||
},
|
||||
"colour": "secondary",
|
||||
"required": true
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
{
|
||||
"toyMounts": {
|
||||
"category": "Toy Mounts",
|
||||
"type": "mod",
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-24mm-threaded",
|
||||
"name": "Toy Mount Flange Base 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 46.26,
|
||||
"timeEstimate": "1h48m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-24mm-threaded.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2.5in ",
|
||||
"name": "Toy Mount Flange Base Dildo Ring 2.5in",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2_5in.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2in",
|
||||
"name": "Toy Mount Flange Base Dildo Ring 2in",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2in.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-24mm-threaded",
|
||||
"name": "Toy Mount Double Double 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-double-double-24mm-threaded.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-rail-mounted",
|
||||
"name": "Toy Mount Double Double Rail Mounted",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-double-double-rail-mounted.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
|
||||
"name": "Toy Mount Sucson Mount Base Plate 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-ring-insert-55mm",
|
||||
"name": "Toy Mount Sucson Mount Ring Insert 55mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-ring-insert-55mm.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-threaded-ring",
|
||||
"name": "Toy Mount Sucson Mount Threaded Ring",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-threaded-ring.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-110mm",
|
||||
"name": "Toy Mount Tie Down and Suction Plate 110mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-110mm.stl"
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-135mm",
|
||||
"name": "Toy Mount Tie Down and Suction Plate 135mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-135mm.stl"
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "toy-mount-hardware",
|
||||
"required": true,
|
||||
"relatedParts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
14
website/src/data/components/toyMounts/index.js
Normal file
14
website/src/data/components/toyMounts/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import ossm from './ossm.json';
|
||||
|
||||
const rawParts = [ossm];
|
||||
|
||||
const mergedToyMounts = {
|
||||
category: "Toy Mounts",
|
||||
type: "mod",
|
||||
printedParts: rawParts.flatMap(v => v.printedParts || []),
|
||||
hardwareParts: rawParts.flatMap(v => v.hardwareParts || [])
|
||||
};
|
||||
|
||||
export default {
|
||||
toyMounts: mergedToyMounts
|
||||
};
|
||||
211
website/src/data/components/toyMounts/ossm.json
Normal file
211
website/src/data/components/toyMounts/ossm.json
Normal file
@@ -0,0 +1,211 @@
|
||||
{
|
||||
"printedParts": [
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-24mm-threaded",
|
||||
"name": "Toy Mount Flange Base 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 46.26,
|
||||
"timeEstimate": "1h48m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-24mm-threaded.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Flange%20Base%2024mm%20Threaded.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-flange-base-24mm-threaded",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base 24mm Threaded.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2.5in ",
|
||||
"name": "Toy Mount Flange Base Dildo Ring 2.5in",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2_5in.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Flange%20Base%20Dildo%20Ring%202.5in.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-flange-base-dildo-ring-2.5in ",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base Dildo Ring 2.5in.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-flange-base-dildo-ring-2in",
|
||||
"name": "Toy Mount Flange Base Dildo Ring 2in",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-flange-base-dildo-ring-2in.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Flange%20Base%20Dildo%20Ring%202in.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-flange-base-dildo-ring-2in",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Flange Base Dildo Ring 2in.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-24mm-threaded",
|
||||
"name": "Toy Mount Double Double 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-double-double-24mm-threaded.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Double%20Double%2024mm%20Threaded.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-double-double-24mm-threaded",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Double Double 24mm Threaded.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-double-double-rail-mounted",
|
||||
"name": "Toy Mount Double Double Rail Mounted",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-double-double-rail-mounted.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Double%20Double%20Rail%20Mounted.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-double-double-rail-mounted",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Double Double Rail Mounted.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
|
||||
"name": "Toy Mount Sucson Mount Base Plate 24mm Threaded",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Sucson%20Mount%20Base%20Plate%2024mm%20Threaded.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Base Plate 24mm Threaded.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-ring-insert-55mm",
|
||||
"name": "Toy Mount Sucson Mount Ring Insert 55mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "primary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-ring-insert-55mm.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Sucson%20Mount%20Ring%20Insert%2055mm.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-sucson-mount-ring-insert-55mm",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Ring Insert 55mm.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-sucson-mount-threaded-ring",
|
||||
"name": "Toy Mount Sucson Mount Threaded Ring",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-sucson-mount-threaded-ring.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Sucson%20Mount%20Threaded%20Ring.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-sucson-mount-threaded-ring",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Sucson Mount Threaded Ring.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-110mm",
|
||||
"name": "Toy Mount Tie Down and Suction Plate 110mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-110mm.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Tie%20Down%20and%20Suction%20Plate%20110mm.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-tie-down-and-suction-plate-110mm",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Tie Down and Suction Plate 110mm.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ossm-toy-mount-tie-down-and-suction-plate-135mm",
|
||||
"name": "Toy Mount Tie Down and Suction Plate 135mm",
|
||||
"description": "Toy mount system",
|
||||
"filamentEstimate": 15.24,
|
||||
"timeEstimate": "55m",
|
||||
"colour": "secondary",
|
||||
"required": true,
|
||||
"filePath": "ossm-toy-mount-tie-down-and-suction-plate-135mm.stl",
|
||||
"url": "https://action.github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/Toy%20Mounts/OSSM%20-%20Toy%20Mount%20Tie%20Down%20and%20Suction%20Plate%20135mm.stl?raw=true",
|
||||
"vendor": {
|
||||
"manifest_id": "ossm-toy-mount-tie-down-and-suction-plate-135mm",
|
||||
"local_path": "vendor/KinkyMakers-OSSM-hardware/Printed Parts/Toy Mounts/OSSM - Toy Mount Tie Down and Suction Plate 135mm.stl",
|
||||
"pinned_sha": null,
|
||||
"pinned_raw_url": null,
|
||||
"checksum_sha256": null,
|
||||
"last_checked": null,
|
||||
"status": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"hardwareParts": [
|
||||
{
|
||||
"id": "toy-mount-hardware",
|
||||
"required": true,
|
||||
"relatedParts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
120
website/src/data/config/options.json
Normal file
120
website/src/data/config/options.json
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"actuator": {
|
||||
"title": "Actuator",
|
||||
"sections": {
|
||||
"mounts": {
|
||||
"title": "Mounts",
|
||||
"useComponents": "mounting",
|
||||
"componentIds": [
|
||||
"middle-pivot",
|
||||
"pitclamp"
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"cover": {
|
||||
"title": "Cover",
|
||||
"options": [
|
||||
{
|
||||
"id": "standard-cover",
|
||||
"name": "Standard Cover",
|
||||
"description": "Standard actuator cover",
|
||||
"image": null,
|
||||
"filamentEstimate": "~27.61g",
|
||||
"type": "base",
|
||||
"componentId": "ossm-actuator-body-cover"
|
||||
},
|
||||
{
|
||||
"id": "blank-cover",
|
||||
"name": "Blank Cover",
|
||||
"description": "Blank cover option",
|
||||
"image": null,
|
||||
"filamentEstimate": "0g",
|
||||
"type": "base"
|
||||
}
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"pcbMount": {
|
||||
"title": "PCB Mount",
|
||||
"useComponents": "pcbMounts",
|
||||
"componentIds": [
|
||||
"3030-mount",
|
||||
"aio-cover-mount"
|
||||
],
|
||||
"isMultiSelect": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"stand": {
|
||||
"title": "Stand",
|
||||
"sections": {
|
||||
"hinges": {
|
||||
"title": "Hinges",
|
||||
"useComponents": "hinges",
|
||||
"componentIds": [
|
||||
"pivot-plate",
|
||||
"pitclamp-reinforced-3030"
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"feet": {
|
||||
"title": "Feet",
|
||||
"useComponents": "feet",
|
||||
"componentIds": [
|
||||
"standard-feet",
|
||||
"suction-feet"
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"crossbarSupports": {
|
||||
"title": "Crossbar Supports",
|
||||
"useComponents": "crossbarSupports",
|
||||
"componentIds": [
|
||||
"standard-90-degree-support",
|
||||
"3d-printed-90-degree-support"
|
||||
],
|
||||
"isMultiSelect": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"toyMounts": {
|
||||
"title": "Toy Mounts",
|
||||
"useComponents": "toyMounts",
|
||||
"sections": {
|
||||
"vacULock": {
|
||||
"title": "Vac-U-Lock",
|
||||
"componentIds": [
|
||||
"ossm-toy-mount-double-double-24mm-threaded",
|
||||
"ossm-toy-mount-double-double-rail-mounted"
|
||||
],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"flangeMount": {
|
||||
"title": "Flange Mount",
|
||||
"componentIds": [
|
||||
"ossm-toy-mount-flange-base-24mm-threaded",
|
||||
"ossm-toy-mount-flange-base-dildo-ring-2.5in",
|
||||
"ossm-toy-mount-flange-base-dildo-ring-2in"
|
||||
],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"suCSOn": {
|
||||
"title": "SuCSOn",
|
||||
"componentIds": [
|
||||
"ossm-toy-mount-sucson-mount-base-plate-24mm-threaded",
|
||||
"ossm-toy-mount-sucson-mount-ring-insert-55mm",
|
||||
"ossm-toy-mount-sucson-mount-threaded-ring"
|
||||
],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"tieDown": {
|
||||
"title": "TieDown",
|
||||
"componentIds": [
|
||||
"ossm-toy-mount-tie-down-and-suction-plate-110mm",
|
||||
"ossm-toy-mount-tie-down-and-suction-plate-135mm"
|
||||
],
|
||||
"isMultiSelect": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import motors from './motors.json';
|
||||
import powerSupplies from './powerSupplies.json';
|
||||
import optionsData from './options.json';
|
||||
import colors from './colors.json';
|
||||
import hardwareData from './hardware.json';
|
||||
import motors from './components/motors.json';
|
||||
import powerSupplies from './components/powerSupplies.json';
|
||||
import pcbs from './components/pcb.json';
|
||||
import optionsData from './config/options.json';
|
||||
import colors from './common/colors.json';
|
||||
import hardwareData from './common/hardware.json';
|
||||
import actuatorComponents from './components/actuator.json';
|
||||
import standComponents from './components/stand.json';
|
||||
import mountingComponents from './components/mounting.json';
|
||||
import toyMountsComponents from './components/toyMounts.json';
|
||||
import mountingComponents from './components/mounting/index.js';
|
||||
import toyMountsComponents from './components/toyMounts/index.js';
|
||||
import remoteComponents from './components/remote.json';
|
||||
import pcbComponents from './components/pcb.json';
|
||||
import pcbComponents from './components/pcb/index.js';
|
||||
|
||||
// Create a hardware lookup map from hardware.json
|
||||
const hardwareLookup = new Map();
|
||||
@@ -21,10 +22,10 @@ Object.values(hardwareData).forEach((category) => {
|
||||
// Function to resolve hardware references (IDs) to full hardware definitions
|
||||
const resolveHardwareReferences = (components) => {
|
||||
const resolvedComponents = {};
|
||||
|
||||
|
||||
Object.entries(components).forEach(([componentKey, component]) => {
|
||||
resolvedComponents[componentKey] = { ...component };
|
||||
|
||||
|
||||
// Resolve hardwareParts if they exist
|
||||
if (component.hardwareParts) {
|
||||
resolvedComponents[componentKey].hardwareParts = component.hardwareParts.map((hw) => {
|
||||
@@ -51,7 +52,7 @@ const resolveHardwareReferences = (components) => {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Also resolve hardwareParts in systems
|
||||
if (component.systems) {
|
||||
resolvedComponents[componentKey].systems = {};
|
||||
@@ -82,7 +83,7 @@ const resolveHardwareReferences = (components) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return resolvedComponents;
|
||||
};
|
||||
|
||||
@@ -133,7 +134,7 @@ const convertComponentPartsToOptions = (componentIds, componentData) => {
|
||||
});
|
||||
return allKnobs;
|
||||
}
|
||||
|
||||
|
||||
// New structure: systems with printedParts and hardwareParts (for hinges, etc.)
|
||||
return componentIds
|
||||
.map((systemId) => {
|
||||
@@ -142,12 +143,12 @@ const convertComponentPartsToOptions = (componentIds, componentData) => {
|
||||
console.warn(`Component system not found: ${systemId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Calculate total filament estimate from printed parts
|
||||
const totalFilament = (system.printedParts || system.bodyParts)?.reduce((sum, part) => {
|
||||
return sum + (part.filamentEstimate || 0);
|
||||
}, 0) || 0;
|
||||
|
||||
|
||||
return {
|
||||
id: systemId,
|
||||
name: system.name,
|
||||
@@ -176,7 +177,7 @@ const convertComponentPartsToOptions = (componentIds, componentData) => {
|
||||
console.warn(`Component part not found: ${componentId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
@@ -196,24 +197,24 @@ const convertComponentPartsToOptions = (componentIds, componentData) => {
|
||||
// Merge component options into options
|
||||
const processOptions = (options, componentsData) => {
|
||||
const processedOptions = { ...options };
|
||||
|
||||
|
||||
// Process each option category
|
||||
Object.keys(processedOptions).forEach((optionKey) => {
|
||||
const optionCategory = processedOptions[optionKey];
|
||||
if (!optionCategory || !optionCategory.sections) return;
|
||||
|
||||
|
||||
const sections = { ...optionCategory.sections };
|
||||
const categoryUseComponents = optionCategory.useComponents;
|
||||
|
||||
|
||||
// Convert component parts to options format for each section
|
||||
Object.keys(sections).forEach((sectionKey) => {
|
||||
const section = sections[sectionKey];
|
||||
|
||||
|
||||
// Check if section has componentIds to process
|
||||
if (section.componentIds !== undefined) {
|
||||
// Determine which component category to use
|
||||
const componentKey = section.useComponents || categoryUseComponents;
|
||||
|
||||
|
||||
if (componentKey) {
|
||||
const componentData = componentsData[componentKey];
|
||||
const options = convertComponentPartsToOptions(section.componentIds, componentData);
|
||||
@@ -224,19 +225,19 @@ const processOptions = (options, componentsData) => {
|
||||
console.warn(`No useComponents specified for ${optionKey}.${sectionKey}`);
|
||||
section.options = [];
|
||||
}
|
||||
|
||||
|
||||
// Clean up temporary properties
|
||||
delete section.componentIds;
|
||||
delete section.useComponents;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
processedOptions[optionKey].sections = sections;
|
||||
if (categoryUseComponents) {
|
||||
delete processedOptions[optionKey].useComponents;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return processedOptions;
|
||||
};
|
||||
|
||||
@@ -245,6 +246,7 @@ const options = processOptions(optionsData, components);
|
||||
export default {
|
||||
motors,
|
||||
powerSupplies,
|
||||
pcbs,
|
||||
options,
|
||||
colors,
|
||||
components,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "57AIM30",
|
||||
"name": "57AIM30 \"Gold Motor\"",
|
||||
"description": "Standard NEMA 17 stepper motor with 1.8° step angle",
|
||||
"speed": "1500 RPM",
|
||||
"wattage": "100W",
|
||||
"gear_count": "RS485",
|
||||
"price": "$125-$250",
|
||||
"image": "/images/motors/57AIM30.png",
|
||||
"required": true,
|
||||
"recommended": true
|
||||
},
|
||||
{
|
||||
"id": "42AIM30",
|
||||
"name": "42AIM30 \"Round Motor\"",
|
||||
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
|
||||
"speed": "1500 RPM",
|
||||
"wattage": "100W",
|
||||
"gear_count": "RS485",
|
||||
"price": "$135-$270",
|
||||
"image": "/images/motors/42AIM30.png",
|
||||
"required": true,
|
||||
"recommended": false
|
||||
},
|
||||
{
|
||||
"id": "iHSV57",
|
||||
"name": "iHSV57 \"Legacy Motor\"",
|
||||
"description": "High precision NEMA 17 stepper motor with 0.9° step angle",
|
||||
"speed": "3000 RPM",
|
||||
"wattage": "180W",
|
||||
"gear_count": "RS485",
|
||||
"price": "$150-$300",
|
||||
"image": "/images/motors/iHSV57.png",
|
||||
"required": true,
|
||||
"recommended": false
|
||||
}
|
||||
]
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"actuator": {
|
||||
"title": "Actuator",
|
||||
"sections": {
|
||||
"mounts": {
|
||||
"title": "Mounts",
|
||||
"options": [
|
||||
{
|
||||
"id": "middle-pivot",
|
||||
"name": "Middle Pivot",
|
||||
"description": "Middle Pivot mounting system",
|
||||
"image": "/images/options/middle-pivot.png",
|
||||
"filamentEstimate": "~147g",
|
||||
"type": "base"
|
||||
},
|
||||
{
|
||||
"id": "pitclamp",
|
||||
"name": "PitClamp Mini",
|
||||
"description": "PitClamp Mini mounting system",
|
||||
"image": "/images/options/PitClamp Mini Base.png",
|
||||
"filamentEstimate": "~137g",
|
||||
"type": "base"
|
||||
}
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"cover": {
|
||||
"title": "Cover",
|
||||
"options": [
|
||||
{
|
||||
"id": "standard-cover",
|
||||
"name": "Standard Cover",
|
||||
"description": "Standard actuator cover",
|
||||
"image": null,
|
||||
"filamentEstimate": "~27.61g",
|
||||
"type": "base",
|
||||
"componentId": "ossm-actuator-body-cover"
|
||||
},
|
||||
{
|
||||
"id": "blank-cover",
|
||||
"name": "Blank Cover",
|
||||
"description": "Blank cover option",
|
||||
"image": null,
|
||||
"filamentEstimate": "0g",
|
||||
"type": "base"
|
||||
}
|
||||
],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"pcbMount": {
|
||||
"title": "PCB Mount",
|
||||
"options": [
|
||||
{
|
||||
"id": "3030-mount",
|
||||
"name": "3030 Mount",
|
||||
"description": "PCB mount for 3030 extrusion",
|
||||
"image": null,
|
||||
"filamentEstimate": null,
|
||||
"type": "base"
|
||||
},
|
||||
{
|
||||
"id": "aio-cover-mount",
|
||||
"name": "AIO Cover Mount",
|
||||
"description": "All-in-one cover mount on the actuator",
|
||||
"image": null,
|
||||
"filamentEstimate": null,
|
||||
"type": "base"
|
||||
}
|
||||
],
|
||||
"isMultiSelect": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"stand": {
|
||||
"title": "Stand",
|
||||
"sections": {
|
||||
"hinges": {
|
||||
"title": "Hinges",
|
||||
"useComponents": "hinges",
|
||||
"componentIds": ["pivot-plate", "pitclamp-reinforced-3030"],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"feet": {
|
||||
"title": "Feet",
|
||||
"useComponents": "feet",
|
||||
"componentIds": ["standard-feet", "suction-feet"],
|
||||
"isMultiSelect": false
|
||||
},
|
||||
"crossbarSupports": {
|
||||
"title": "Crossbar Supports",
|
||||
"useComponents": "crossbarSupports",
|
||||
"componentIds": ["standard-90-degree-support", "3d-printed-90-degree-support"],
|
||||
"isMultiSelect": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"toyMounts": {
|
||||
"title": "Toy Mounts",
|
||||
"useComponents": "toyMounts",
|
||||
"sections": {
|
||||
"vacULock": {
|
||||
"title": "Vac-U-Lock",
|
||||
"componentIds": ["ossm-toy-mount-double-double-24mm-threaded", "ossm-toy-mount-double-double-rail-mounted"],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"flangeMount": {
|
||||
"title": "Flange Mount",
|
||||
"componentIds": ["ossm-toy-mount-flange-base-24mm-threaded", "ossm-toy-mount-flange-base-dildo-ring-2.5in", "ossm-toy-mount-flange-base-dildo-ring-2in"],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"suCSOn": {
|
||||
"title": "SuCSOn",
|
||||
"componentIds": ["ossm-toy-mount-sucson-mount-base-plate-24mm-threaded", "ossm-toy-mount-sucson-mount-ring-insert-55mm", "ossm-toy-mount-sucson-mount-threaded-ring"],
|
||||
"isMultiSelect": true
|
||||
},
|
||||
"tieDown": {
|
||||
"title": "TieDown",
|
||||
"componentIds": ["ossm-toy-mount-tie-down-and-suction-plate-110mm", "ossm-toy-mount-tie-down-and-suction-plate-135mm"],
|
||||
"isMultiSelect": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "psu-24v-5a",
|
||||
"name": "24V 5A Power Supply",
|
||||
"description": "24V DC power supply, 5A output",
|
||||
"voltage": "24V",
|
||||
"current": "5A",
|
||||
"price": 20,
|
||||
"image": "/images/power-supplies/24v-PSU.png",
|
||||
"compatibleMotors": [
|
||||
"57AIM30",
|
||||
"42AIM30",
|
||||
"iHSV57"
|
||||
],
|
||||
"required": true,
|
||||
"links": [
|
||||
{
|
||||
"store": "Amazon",
|
||||
"link": "https://www.amazon.ca/Adapter-Female-5-5x2-5mm-Printer-Generator/dp/B0CR7DBKX5/ref=sr_1_5?crid=8CCHI94WM1J2&dib=eyJ2IjoiMSJ9.THY1sfJvVZbDjX-py4dIhAQXj69L2lE1OXB-OZijGqhizoxtEtZo3mrvVSGttuDBQXEHAAMoWxabFOZCD_9Drj4m3NxldA6I3NP2YB3LS14b2_uszbzhrCF_Xyu588Mzhuc59YSTgo3hw_uCub4NUFQZP-hGloBM4rXUYSgKsWrT_RL3l4dzQM9aY0QPVuDUbJreMnLwMF_rOkiH9r2-7jKHwDcEoVH8eQ09rVpXVyUqpcStI62_O2Rq17mu_YexGSyz3_9mznJvQlMPgg_DVBFvg69rhvcjbguSMVP8TG8.iVFiqorJkZztDuddLlNrSh0CRknKRiOp2VbJRHl7RRs&dib_tag=se&keywords=USB%2BC%2BTo%2BDC%2B5.5x2.5mm%2BAdapter&qid=1767501555&sprefix=usb%2Bc%2Bto%2Bdc%2B5%2B5x2%2B5mm%2Badapter%2Caps%2C127&sr=8-5&th=1"
|
||||
},
|
||||
{
|
||||
"store": "AliExpress",
|
||||
"link": "https://www.aliexpress.com/item/100500312131213.html"
|
||||
},
|
||||
{
|
||||
"store": "Research & Desire",
|
||||
"link": "https://www.researchanddesire.com/products/ossm-24v-power-supply"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "psu-24v-usbc-pd",
|
||||
"name": "24v USB-C PD Adapter",
|
||||
"description": "24V USB-C PD Adapter, Requires 100W+ Power Supply",
|
||||
"voltage": "24V",
|
||||
"current": "5A",
|
||||
"price": 30,
|
||||
"image": "/images/power-supplies/24v-usbc-pd.png",
|
||||
"compatibleMotors": [
|
||||
"57AIM30",
|
||||
"42AIM30",
|
||||
"iHSV57"
|
||||
],
|
||||
"required": true,
|
||||
"links": [
|
||||
{
|
||||
"store": "Amazon",
|
||||
"link": "https://www.amazon.ca/Adapter-Female-5-5x2-5mm-Printer-Generator/dp/B0CR7DBKX5/ref=sr_1_5?crid=8CCHI94WM1J2&dib=eyJ2IjoiMSJ9.THY1sfJvVZbDjX-py4dIhAQXj69L2lE1OXB-OZijGqhizoxtEtZo3mrvVSGttuDBQXEHAAMoWxabFOZCD_9Drj4m3NxldA6I3NP2YB3LS14b2_uszbzhrCF_Xyu588Mzhuc59YSTgo3hw_uCub4NUFQZP-hGloBM4rXUYSgKsWrT_RL3l4dzQM9aY0QPVuDUbJreMnLwMF_rOkiH9r2-7jKHwDcEoVH8eQ09rVpXVyUqpcStI62_O2Rq17mu_YexGSyz3_9mznJvQlMPgg_DVBFvg69rhvcjbguSMVP8TG8.iVFiqorJkZztDuddLlNrSh0CRknKRiOp2VbJRHl7RRs&dib_tag=se&keywords=USB%2BC%2BTo%2BDC%2B5.5x2.5mm%2BAdapter&qid=1767501555&sprefix=usb%2Bc%2Bto%2Bdc%2B5%2B5x2%2B5mm%2Badapter%2Caps%2C127&sr=8-5&th=1"
|
||||
},
|
||||
{
|
||||
"store": "AliExpress",
|
||||
"link": "https://www.aliexpress.com/item/100500312131213.html"
|
||||
},
|
||||
{
|
||||
"store": "Research & Desire",
|
||||
"link": "https://www.researchanddesire.com/products/ossm-24v-usb-c-adapter"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
39
website/src/hooks/usePriceFormat.js
Normal file
39
website/src/hooks/usePriceFormat.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCurrency } from '../contexts/CurrencyContext';
|
||||
import { formatPrice as formatPriceUtil } from '../utils/priceFormat';
|
||||
import { convertPrice } from '../utils/currencyService';
|
||||
|
||||
/**
|
||||
* Hook to format prices using the selected currency from context with conversion
|
||||
*/
|
||||
export function usePriceFormat() {
|
||||
const { currency, exchangeRates } = useCurrency();
|
||||
const [convertedPriceCache, setConvertedPriceCache] = useState(new Map());
|
||||
|
||||
const formatPrice = async (price, preferredCurrency = null) => {
|
||||
const displayCurrency = preferredCurrency || currency;
|
||||
|
||||
// Convert price to target currency if needed
|
||||
if (exchangeRates && price) {
|
||||
try {
|
||||
const converted = await convertPrice(price, displayCurrency, exchangeRates);
|
||||
return formatPriceUtil(converted, displayCurrency);
|
||||
} catch (error) {
|
||||
console.warn('Failed to convert price, using original:', error);
|
||||
return formatPriceUtil(price, displayCurrency);
|
||||
}
|
||||
}
|
||||
|
||||
return formatPriceUtil(price, displayCurrency);
|
||||
};
|
||||
|
||||
// Synchronous version for use in render (uses cache or returns promise)
|
||||
const formatPriceSync = (price, preferredCurrency = null) => {
|
||||
const displayCurrency = preferredCurrency || currency;
|
||||
// For now, return the formatted price without conversion in sync mode
|
||||
// Conversion will happen in components that can handle async
|
||||
return formatPriceUtil(price, displayCurrency);
|
||||
};
|
||||
|
||||
return { formatPrice, formatPriceSync, currency, exchangeRates };
|
||||
}
|
||||
@@ -3,11 +3,18 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { CurrencyProvider } from './contexts/CurrencyContext'
|
||||
import { preloadExchangeRates } from './utils/currencyService'
|
||||
|
||||
// Preload exchange rates on app start
|
||||
preloadExchangeRates();
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
<CurrencyProvider>
|
||||
<App />
|
||||
</CurrencyProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
317
website/src/utils/bomUtils.js
Normal file
317
website/src/utils/bomUtils.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import partsData from '../data/index.js';
|
||||
import { getNumericPrice, extractNumericPrice, formatPrice, formatPriceWithConversion } from './priceFormat';
|
||||
import { convertPrice } from './currencyService';
|
||||
|
||||
/**
|
||||
* Evaluate a condition object against the config
|
||||
*/
|
||||
export const evaluateCondition = (condition, config) => {
|
||||
if (!condition) return true;
|
||||
|
||||
return Object.entries(condition).every(([key, value]) => {
|
||||
// Handle dot notation for nested config (e.g., motor.id)
|
||||
const keys = key.split('.');
|
||||
let current = config;
|
||||
for (const k of keys) {
|
||||
if (current === null || current === undefined) return false;
|
||||
current = current[k];
|
||||
}
|
||||
return current === value;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a component should be included based on config selections
|
||||
*/
|
||||
export const shouldIncludeComponent = (componentKey, config) => {
|
||||
// Actuator is always included (it's the base component)
|
||||
if (componentKey === 'actuator') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mounting: only if mount is selected
|
||||
if (componentKey === 'mounting' || componentKey === 'mounts') {
|
||||
return !!config.mount;
|
||||
}
|
||||
|
||||
// Stand components: only if stand options are selected
|
||||
if (componentKey === 'stand') {
|
||||
return !!(config.standFeet || config.standHinge || (config.standCrossbarSupports && config.standCrossbarSupports.length > 0));
|
||||
}
|
||||
|
||||
// Feet: only if standFeet is selected
|
||||
if (componentKey === 'feet') {
|
||||
return !!config.standFeet;
|
||||
}
|
||||
|
||||
// Hinges: only if standHinge is selected
|
||||
if (componentKey === 'hinges') {
|
||||
return !!config.standHinge;
|
||||
}
|
||||
|
||||
// Crossbar supports: only if standCrossbarSupports are selected
|
||||
if (componentKey === 'crossbarSupports') {
|
||||
return !!(config.standCrossbarSupports && config.standCrossbarSupports.length > 0);
|
||||
}
|
||||
|
||||
// Remotes: only if remote is selected
|
||||
if (componentKey === 'remotes') {
|
||||
return !!(config.remoteKnob || config.remoteType || config.remote?.id);
|
||||
}
|
||||
|
||||
// Toy mounts: only if toy mount options are selected
|
||||
if (componentKey === 'toyMounts') {
|
||||
return !!(config.toyMountOptions && config.toyMountOptions.length > 0);
|
||||
}
|
||||
|
||||
// PCB: only if pcbMount is selected
|
||||
if (componentKey === 'pcb' || componentKey === 'pcbMount') {
|
||||
return !!config.pcbMount;
|
||||
}
|
||||
|
||||
// By default, don't include other components unless explicitly selected
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get minimum price from links or fallback to price field
|
||||
*/
|
||||
export const getPriceFromLinks = (item) => {
|
||||
if (!item) return 0;
|
||||
if (item.links && item.links.length > 0) {
|
||||
const prices = item.links.map(link => extractNumericPrice(link.price)).filter(p => p != null && p > 0);
|
||||
if (prices.length > 0) {
|
||||
return Math.min(...prices);
|
||||
}
|
||||
}
|
||||
// Fallback to old price field if links don't have prices
|
||||
return getNumericPrice(item.price);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get price range or single price from links for display (synchronous version, no conversion)
|
||||
*/
|
||||
export const getPriceDisplayFromLinks = (item, targetCurrency = null) => {
|
||||
if (!item) return 'C$0.00';
|
||||
if (item.links && item.links.length > 0) {
|
||||
// Get price objects (with currency) from links, filtering out null/invalid prices
|
||||
const priceObjects = item.links
|
||||
.map(link => link.price)
|
||||
.filter(price => price && (price.amount || (typeof price === 'object' && 'amount' in price)));
|
||||
|
||||
if (priceObjects.length === 0) return 'C$0.00';
|
||||
|
||||
// If all prices have the same currency, show range with that currency
|
||||
const currencies = priceObjects
|
||||
.map(p => p?.currency || 'CAD')
|
||||
.filter((v, i, a) => a.indexOf(v) === i);
|
||||
const isSingleCurrency = currencies.length === 1;
|
||||
|
||||
// Extract numeric values for min/max calculation
|
||||
// For overall min: use the minimum values from each price (min from ranges, single prices, etc.)
|
||||
// For overall max: use the maximum values from each price (max from ranges, single prices, etc.)
|
||||
const minValues = priceObjects.map(p => {
|
||||
if (typeof p === 'object' && 'amount' in p) {
|
||||
const amount = p.amount;
|
||||
// For ranges, use the min value; for single prices, use the amount
|
||||
if (typeof amount === 'object' && 'min' in amount) {
|
||||
return amount.min;
|
||||
}
|
||||
if (typeof amount === 'number') {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
const extracted = extractNumericPrice(p);
|
||||
return extracted != null ? extracted : 0;
|
||||
}).filter(p => p != null && p > 0);
|
||||
|
||||
const maxValues = priceObjects.map(p => {
|
||||
if (typeof p === 'object' && 'amount' in p) {
|
||||
const amount = p.amount;
|
||||
// For ranges, use the max value; for single prices, use the amount
|
||||
if (typeof amount === 'object' && 'max' in amount) {
|
||||
return amount.max;
|
||||
}
|
||||
if (typeof amount === 'object' && 'min' in amount) {
|
||||
return amount.min; // If no max, use min (single value range)
|
||||
}
|
||||
if (typeof amount === 'number') {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
const extracted = extractNumericPrice(p);
|
||||
return extracted != null ? extracted : 0;
|
||||
}).filter(p => p != null && p > 0);
|
||||
|
||||
if (minValues.length === 0 || maxValues.length === 0) return 'C$0.00';
|
||||
|
||||
const minPrice = Math.min(...minValues);
|
||||
const maxPrice = Math.max(...maxValues);
|
||||
|
||||
if (minPrice === maxPrice) {
|
||||
// Single price - format with currency from the first link
|
||||
return formatPrice(priceObjects[0], targetCurrency || 'CAD');
|
||||
}
|
||||
|
||||
// Price range - format both with their respective currencies if different, or same currency if same
|
||||
if (isSingleCurrency) {
|
||||
const currency = targetCurrency || currencies[0];
|
||||
const currencySymbol = currency === 'CAD' ? 'C$' : currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : currency;
|
||||
return `${currencySymbol}${minPrice.toFixed(2)} - ${currencySymbol}${maxPrice.toFixed(2)}`;
|
||||
} else {
|
||||
// Multiple currencies - find the price objects that contain the overall min and max
|
||||
const minPriceObj = priceObjects.find(p => {
|
||||
if (typeof p === 'object' && 'amount' in p) {
|
||||
const amount = p.amount;
|
||||
if (typeof amount === 'object' && 'min' in amount) {
|
||||
return amount.min === minPrice;
|
||||
}
|
||||
if (typeof amount === 'number') {
|
||||
return amount === minPrice;
|
||||
}
|
||||
}
|
||||
return extractNumericPrice(p) === minPrice;
|
||||
}) || priceObjects[0]; // Fallback to first if not found
|
||||
|
||||
const maxPriceObj = priceObjects.find(p => {
|
||||
if (typeof p === 'object' && 'amount' in p) {
|
||||
const amount = p.amount;
|
||||
if (typeof amount === 'object' && 'max' in amount) {
|
||||
return amount.max === maxPrice;
|
||||
}
|
||||
if (typeof amount === 'object' && 'min' in amount) {
|
||||
return amount.min === maxPrice; // Single value range
|
||||
}
|
||||
if (typeof amount === 'number') {
|
||||
return amount === maxPrice;
|
||||
}
|
||||
}
|
||||
return extractNumericPrice(p) === maxPrice;
|
||||
}) || priceObjects[priceObjects.length - 1]; // Fallback to last if not found
|
||||
|
||||
return `${formatPrice(minPriceObj, targetCurrency || 'CAD')} - ${formatPrice(maxPriceObj, targetCurrency || 'CAD')}`;
|
||||
}
|
||||
}
|
||||
// Fallback to old price field if links don't exist
|
||||
return formatPrice(item.price || 0, targetCurrency || 'CAD');
|
||||
};
|
||||
|
||||
/**
|
||||
* Async version with currency conversion
|
||||
*/
|
||||
export const getPriceDisplayFromLinksAsync = async (item, targetCurrency = 'CAD', exchangeRates = null) => {
|
||||
if (!item) return 'C$0.00';
|
||||
|
||||
if (item.links && item.links.length > 0) {
|
||||
// Convert all prices to target currency first
|
||||
const convertedPrices = await Promise.all(
|
||||
item.links
|
||||
.map(link => link.price)
|
||||
.filter(price => price && (price.amount || (typeof price === 'object' && 'amount' in price)))
|
||||
.map(async (price) => {
|
||||
if (exchangeRates) {
|
||||
return await convertPrice(price, targetCurrency, exchangeRates);
|
||||
}
|
||||
return price;
|
||||
})
|
||||
);
|
||||
|
||||
if (convertedPrices.length === 0) return 'C$0.00';
|
||||
|
||||
// Extract numeric values for min/max calculation
|
||||
// For overall min: use the minimum values from each price (min from ranges, single prices, etc.)
|
||||
// For overall max: use the maximum values from each price (max from ranges, single prices, etc.)
|
||||
const minValues = convertedPrices.map(p => {
|
||||
if (typeof p === 'object' && 'amount' in p) {
|
||||
const amount = p.amount;
|
||||
// For ranges, use the min value; for single prices, use the amount
|
||||
if (typeof amount === 'object' && 'min' in amount) {
|
||||
return amount.min;
|
||||
}
|
||||
if (typeof amount === 'number') {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
const extracted = extractNumericPrice(p);
|
||||
return extracted != null ? extracted : 0;
|
||||
}).filter(p => p != null && p > 0);
|
||||
|
||||
const maxValues = convertedPrices.map(p => {
|
||||
if (typeof p === 'object' && 'amount' in p) {
|
||||
const amount = p.amount;
|
||||
// For ranges, use the max value; for single prices, use the amount
|
||||
if (typeof amount === 'object' && 'max' in amount) {
|
||||
return amount.max;
|
||||
}
|
||||
if (typeof amount === 'object' && 'min' in amount) {
|
||||
return amount.min; // If no max, use min (single value range)
|
||||
}
|
||||
if (typeof amount === 'number') {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
const extracted = extractNumericPrice(p);
|
||||
return extracted != null ? extracted : 0;
|
||||
}).filter(p => p != null && p > 0);
|
||||
|
||||
if (minValues.length === 0 || maxValues.length === 0) return 'C$0.00';
|
||||
|
||||
const minPrice = Math.min(...minValues);
|
||||
const maxPrice = Math.max(...maxValues);
|
||||
|
||||
if (minPrice === maxPrice) {
|
||||
return await formatPriceWithConversion(convertedPrices[0], targetCurrency, exchangeRates);
|
||||
}
|
||||
|
||||
// Price range
|
||||
const currencySymbol = targetCurrency === 'CAD' ? 'C$' : targetCurrency === 'USD' ? '$' : targetCurrency === 'EUR' ? '€' : targetCurrency === 'GBP' ? '£' : targetCurrency;
|
||||
return `${currencySymbol}${minPrice.toFixed(2)} - ${currencySymbol}${maxPrice.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Fallback to old price field
|
||||
if (item.price) {
|
||||
return await formatPriceWithConversion(item.price, targetCurrency, exchangeRates);
|
||||
}
|
||||
|
||||
return 'C$0.00';
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate total hardware cost
|
||||
*/
|
||||
export const calculateTotal = (config) => {
|
||||
let total = 0;
|
||||
|
||||
if (config.motor) total += getPriceFromLinks(config.motor);
|
||||
if (config.powerSupply) total += getPriceFromLinks(config.powerSupply);
|
||||
|
||||
if (config.mount) {
|
||||
const mountOption = partsData.options?.mounts?.find(m => m.id === config.mount.id);
|
||||
if (mountOption?.hardwareCost) total += getNumericPrice(mountOption.hardwareCost);
|
||||
}
|
||||
|
||||
if (config.standHinge) {
|
||||
// Check new structure (systems) first, then fall back to options
|
||||
const hingeSystem = partsData.components?.hinges?.systems?.[config.standHinge.id];
|
||||
if (hingeSystem?.hardwareCost) {
|
||||
total += getNumericPrice(hingeSystem.hardwareCost);
|
||||
} else {
|
||||
const hingeOption = partsData.options?.standHinges?.find(h => h.id === config.standHinge.id);
|
||||
if (hingeOption?.hardwareCost) total += getNumericPrice(hingeOption.hardwareCost);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.standFeet) {
|
||||
const feetOption = partsData.options?.standFeet?.find(f => f.id === config.standFeet.id);
|
||||
if (feetOption?.hardwareCost) total += getNumericPrice(feetOption.hardwareCost);
|
||||
}
|
||||
|
||||
if (config.standCrossbarSupports) {
|
||||
config.standCrossbarSupports.forEach((support) => {
|
||||
const supportOption = partsData.options?.standCrossbarSupports?.find(s => s.id === support.id);
|
||||
if (supportOption?.hardwareCost) total += getNumericPrice(supportOption.hardwareCost);
|
||||
});
|
||||
}
|
||||
|
||||
return total;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user