Compare commits

8 Commits
main ... Dev

Author SHA1 Message Date
MunchDev-oss
ea083485d5 fix: Update compatible motor identifiers in power supplies data
- Changed compatible motor identifier from "42AIM30" to "42AIM" in power supplies JSON to ensure consistency and accuracy in motor compatibility listings.
2026-01-10 03:53:56 -05:00
MunchDev-oss
0c7504b841 refactor: Enhance price extraction logic in BOM and export utilities to handle ranges correctly
- Updated `getPriceDisplayFromLinks` and `getPriceDisplayFromLinksAsync` functions to separately extract minimum and maximum prices from price objects, accommodating both single prices and ranges.
- Refactored `generateMarkdownOverview` to improve price display by handling min and max values accurately, ensuring proper formatting in markdown outputs.
- Added checks to return 'N/A' when no valid prices are found, enhancing robustness of pricing display logic.
2026-01-10 03:10:18 -05:00
MunchDev-oss
aba0964a59 feat: Add prop-types dependency, implement currency context, and enhance pricing display in components
- Added `prop-types` for better prop validation in components.
- Introduced `CurrencyProvider` to manage currency context and preload exchange rates.
- Updated pricing logic in various components to support new price structure and display currency.
- Refactored BOMSummary, MotorStep, and PowerSupplyStep to utilize new pricing methods and improve user experience.
- Enhanced export utilities to format prices correctly in markdown and Excel outputs.
- Updated hardware and component data structures to include detailed pricing information.
2026-01-10 03:04:28 -05:00
MunchDev-oss
86f0acc26b Update vendor manifest with new components, adjust last checked timestamps, and enhance actuator and mounting data structures. Remove deprecated armpitmfg data and improve hardware part definitions across multiple components. 2026-01-10 00:06:45 -05:00
MunchDev-oss
004fcc59df refactor: Restructure mounting and PCB data into modular files, adding new armpitmfg options and updating data references. 2026-01-07 02:55:45 -05:00
MunchDev-oss
5366865b4b refactor: Restructure data files into component-specific and common directories, add new UI components, and update project documentation. 2026-01-07 02:06:43 -05:00
97d2b66f02 Update package version to 0.0.1-beta, add new dependencies including ExcelJS, and refactor export utilities to utilize ExcelJS for Excel file generation. Enhance component JSON files with vendor information for improved asset management. 2026-01-07 02:12:12 +00:00
4bc0fd203f Implement vendor system in OSSM Configurator, enhancing reproducibility of external asset files. Update README with detailed vendor system documentation, including project structure, manifest schema, and integration with component JSON files. Modify package.json and package-lock.json for dependency updates, and refactor export utilities to use ExcelJS instead of XLSX for Excel file generation. 2026-01-07 02:12:03 +00:00
104 changed files with 9144 additions and 3737 deletions

174
.github/workflows/check-vendor.yml vendored Normal file
View 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
View 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
View File

@@ -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. [![Project Version](https://img.shields.io/badge/version-0.0.1--beta-blue.svg)](https://github.com/KinkyMakers/OSSM-Configurator)
[![License](https://img.shields.io/badge/License-OSSM-green.svg)](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 ### 1. Component Selection
- **Component Compatibility**: Ensures selected components are compatible with each other The wizard starts with core hardware. Select your motor and power supply with real-time feedback on compatibility and cost.
- **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
### Technology Stack ![Motor Selection](./Screen%20Shots/Motor-Selected.png)
*Figure 1: Motor selection with clear technical specs and visual feedback.*
- **React 18** - UI framework ![PSU Selection](./Screen%20Shots/PSU%20selector.png)
- **Vite** - Build tool and dev server *Figure 2: Choosing a compatible power supply (24V or USB-C PD).*
- **Tailwind CSS** - Styling
- **JSZip** - For generating downloadable BOM packages
## TODO ### 2. Aesthetic Customization
- [X] Dark Mode [Completed] Choose your Primary and Accent colors. These selections automatically update the filament estimates in your final BOM.
- [ ] 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
### Prerequisites ![Color Selection](./Screen%20Shots/Color%20Selector.png)
*Figure 3: Interactive color picker for 3D printed components.*
**Option 1: Using Docker (Recommended)** ### 3. Detailed Options
- Docker Desktop or Docker Engine Configure every aspect of your machine, from the stand type to specialized toy mounts.
- Docker Compose
**Option 2: Local Development** ![Options Selection](./Screen%20Shots/Options%20List.png)
- Node.js (v16 or higher recommended) *Figure 4: Browsing the extensive list of compatible add-ons and variations.*
- npm or yarn
### 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: ![BOM Summary](./Screen%20Shots/Summary.png)
```bash *Figure 5: The comprehensive BOM summary with automated ZIP generation.*
cd website
```
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 ```bash
docker-compose -f docker-compose-dev.yml up -d docker-compose -f docker-compose-dev.yml up -d
``` ```
Access the app at `http://localhost:5173`.
To stop the development container: ### Local Development
```bash 1. **Clone the repo**
docker-compose -f docker-compose-dev.yml down 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 We are constantly improving the OSSM Configurator. Check out our **[detailed roadmap](./roadmap/ROADMAP.md)** for upcoming features, including:
docker-compose up --build -d - 🛠️ 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): ## 🤝 Contributing
```bash
docker-compose up -d
```
To stop the production container: Contributions are welcome! Whether you are adding a new remote, a toy mount, or a hardware mod, please refer to our detailed guide:
```bash
docker-compose down
```
To view logs: 👉 **[Read the CONTRIBUTING.md](./CONTRIBUTING.md)**
```bash
docker-compose logs -f
```
### 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 ## 📜 License
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
```
## 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/`: ---
*Built with ❤️ by the OSSM Community.*
- `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.

180
api/github_webhook/index.py Executable file
View 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

View 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
View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

268
scripts/check_updates.py Executable file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Tests package

240
tests/test_check_updates.py Normal file
View 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
View 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'])

38
website/eslint.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -6,26 +6,34 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"exceljs": "^4.4.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"prop-types": "^15.8.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1"
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.0.0",
"postcss": "^8.4.41", "postcss": "^8.4.41",
"tailwindcss": "^3.4.9", "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"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -2,14 +2,21 @@ import { useState, useEffect } from 'react';
import MainPage from './components/MainPage'; import MainPage from './components/MainPage';
import Wizard from './components/Wizard'; import Wizard from './components/Wizard';
import ThemeToggle from './components/ThemeToggle'; import ThemeToggle from './components/ThemeToggle';
import CurrencySwitcher from './components/CurrencySwitcher';
import partsData from './data/index.js'; import partsData from './data/index.js';
import { getSharedConfig } from './utils/shareService'; import { getSharedConfig } from './utils/shareService';
function App() { function App() {
const [buildType, setBuildType] = useState(null); 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({ const [config, setConfig] = useState({
motor: null, motor: recommendedMotor,
powerSupply: null, powerSupply: recommendedPSU,
primaryColor: 'black', primaryColor: 'black',
accentColor: 'black', accentColor: 'black',
mount: null, mount: null,
@@ -127,9 +134,14 @@ function App() {
const handleBackToMain = () => { const handleBackToMain = () => {
setBuildType(null); 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({ setConfig({
motor: null, motor: defaultMotor,
powerSupply: null, powerSupply: defaultPSU,
primaryColor: 'black', primaryColor: 'black',
accentColor: 'black', accentColor: 'black',
mount: null, mount: null,
@@ -149,6 +161,7 @@ function App() {
return ( return (
<> <>
<ThemeToggle /> <ThemeToggle />
<CurrencySwitcher />
<MainPage onSelectBuildType={handleSelectBuildType} /> <MainPage onSelectBuildType={handleSelectBuildType} />
</> </>
); );
@@ -157,6 +170,7 @@ function App() {
return ( return (
<> <>
<ThemeToggle /> <ThemeToggle />
<CurrencySwitcher />
<Wizard <Wizard
buildType={buildType} buildType={buildType}
initialConfig={config} initialConfig={config}

File diff suppressed because it is too large Load Diff

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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>
);
}

View 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>
);
}

View File

@@ -1,4 +1,4 @@
import partsData from '../data/index.js'; import Footer from './Footer';
export default function MainPage({ onSelectBuildType }) { export default function MainPage({ onSelectBuildType }) {
const handleSelect = (buildType) => { const handleSelect = (buildType) => {
@@ -6,8 +6,8 @@ export default function MainPage({ onSelectBuildType }) {
}; };
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8"> <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"> <div className="max-w-4xl mx-auto px-4 flex-grow">
{/* Header */} {/* Header */}
<div className="mb-12 text-center"> <div className="mb-12 text-center">
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
@@ -120,6 +120,7 @@ export default function MainPage({ onSelectBuildType }) {
</div> </div>
</div> </div>
</div> </div>
<Footer />
</div> </div>
); );
} }

View File

@@ -6,7 +6,7 @@ export default function ThemeToggle() {
return ( return (
<button <button
onClick={toggleTheme} 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" aria-label="Toggle theme"
> >
{theme === 'dark' ? ( {theme === 'dark' ? (

View File

@@ -6,6 +6,7 @@ import OptionsStep from './steps/OptionsStep';
import RemoteStep from './steps/RemoteStep'; import RemoteStep from './steps/RemoteStep';
import ToyMountStep from './steps/ToyMountStep'; import ToyMountStep from './steps/ToyMountStep';
import BOMSummary from './BOMSummary'; import BOMSummary from './BOMSummary';
import Footer from './Footer';
const steps = [ const steps = [
{ id: 'motor', name: 'Motor', component: MotorStep }, { 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 [currentStep, setCurrentStep] = useState(getInitialStep());
const [config, setConfig] = useState(initialConfig || { const [config, setConfig] = useState(initialConfig || {
motor: '57AIM30', motor: null,
powerSupply: '24V PSU', powerSupply: null,
primaryColor: 'black', primaryColor: 'black',
accentColor: 'black', accentColor: 'black',
mount: 'Middle Pivot', mount: null,
cover: 'Simple', cover: null,
standHinge: 'Pivot Plate', standHinge: null,
standFeet: '3030 Extrusion', standFeet: null,
standCrossbarSupports: 'standard', standCrossbarSupports: [],
pcbMount: null, pcbMount: null,
}); });
@@ -158,8 +159,8 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
}, [buildType, currentStep]); }, [buildType, currentStep]);
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8"> <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"> <div className="max-w-4xl mx-auto px-4 flex-grow w-full">
{/* Back Button */} {/* Back Button */}
{onBackToMain && ( {onBackToMain && (
<div className="mb-4"> <div className="mb-4">
@@ -212,14 +213,12 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
{/* Circle */} {/* Circle */}
<button <button
onClick={() => goToStep(index)} onClick={() => goToStep(index)}
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold flex-shrink-0 z-10 ${ className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold flex-shrink-0 z-10 ${index === currentStep
index === currentStep
? 'bg-blue-600 dark:bg-blue-500 text-white' ? 'bg-blue-600 dark:bg-blue-500 text-white'
: index < currentStep : index < currentStep
? 'bg-green-500 dark:bg-green-600 text-white' ? 'bg-green-500 dark:bg-green-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400' : 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
} ${ } ${index <= currentStep
index <= currentStep
? 'cursor-pointer hover:opacity-80' ? 'cursor-pointer hover:opacity-80'
: 'cursor-not-allowed' : 'cursor-not-allowed'
}`} }`}
@@ -230,8 +229,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
{/* Connecting line to the right */} {/* Connecting line to the right */}
{index < filteredSteps.length - 1 && ( {index < filteredSteps.length - 1 && (
<div <div
className={`absolute top-5 left-1/2 h-1 ${ 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'
index < currentStep ? 'bg-green-500 dark:bg-green-600' : 'bg-gray-200 dark:bg-gray-700'
}`} }`}
style={{ style={{
width: 'calc(100% - 40px)', width: 'calc(100% - 40px)',
@@ -242,8 +240,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
{/* Text label */} {/* Text label */}
<button <button
onClick={() => goToStep(index)} onClick={() => goToStep(index)}
className={`mt-2 text-sm font-medium text-center ${ className={`mt-2 text-sm font-medium text-center ${index <= currentStep
index <= currentStep
? 'text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-800 dark:hover:text-blue-300' ? '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' : 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
}`} }`}
@@ -273,8 +270,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
<button <button
onClick={prevStep} onClick={prevStep}
disabled={currentStep === 0} disabled={currentStep === 0}
className={`px-6 py-2 rounded-lg font-medium ${ className={`px-6 py-2 rounded-lg font-medium ${currentStep === 0
currentStep === 0
? 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed' ? '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' : 'bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
}`} }`}
@@ -285,8 +281,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
<button <button
onClick={nextStep} onClick={nextStep}
disabled={!canProceedToNextStep()} disabled={!canProceedToNextStep()}
className={`px-6 py-2 rounded-lg font-medium ${ className={`px-6 py-2 rounded-lg font-medium ${canProceedToNextStep()
canProceedToNextStep()
? 'bg-blue-600 dark:bg-blue-500 text-white hover:bg-blue-700 dark:hover:bg-blue-600' ? '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' : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
}`} }`}
@@ -297,6 +292,7 @@ export default function Wizard({ buildType = 'self-source', initialConfig, updat
</div> </div>
)} )}
</div> </div>
<Footer />
</div> </div>
); );
} }

View File

@@ -1,8 +1,15 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import partsData from '../../data/index.js'; 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 }) { export default function MotorStep({ config, updateConfig }) {
const selectedMotorId = config.motor?.id; const selectedMotorId = config.motor?.id;
const { currency, exchangeRates } = useCurrency();
const [motorPrices, setMotorPrices] = useState({});
const handleSelect = (motor) => { const handleSelect = (motor) => {
updateConfig({ motor }); updateConfig({ motor });
@@ -12,12 +19,27 @@ export default function MotorStep({ config, updateConfig }) {
const otherMotors = partsData.motors.filter(m => !m.recommended); const otherMotors = partsData.motors.filter(m => !m.recommended);
const hasSingleRecommended = recommendedMotors.length === 1; 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 <button
key={motor.id} key={motor.id}
onClick={() => handleSelect(motor)} onClick={() => handleSelect(motor)}
className={`${isSlightlyLarger ? 'p-5' : 'p-4'} border-2 rounded-lg text-left transition-all ${ className={`w-full ${isSlightlyLarger ? 'p-5' : 'p-4'} border-2 rounded-lg text-left transition-all ${selectedMotorId === motor.id
selectedMotorId === motor.id
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg' ? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg'
: motor.recommended : 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-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'
@@ -25,24 +47,18 @@ export default function MotorStep({ config, updateConfig }) {
}`} }`}
> >
{motor.recommended && ( {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"> <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> </span>
</div> </div>
)} )}
{motor.image && ( <ImageWithFallback
<div className={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}>
<img
src={motor.image} src={motor.image}
alt={motor.name} alt={motor.name}
className={`${isSlightlyLarger ? 'h-32 w-32' : 'h-24 w-24'} object-contain rounded-lg bg-gray-100 dark:bg-gray-700`} className={`${isSlightlyLarger ? 'h-32 w-32' : 'h-24 w-24'} object-contain rounded-lg bg-gray-100 dark:bg-gray-700`}
onError={(e) => { containerClassName={`${isSlightlyLarger ? 'mb-4' : 'mb-3'} flex justify-center`}
e.target.style.display = 'none';
}}
/> />
</div>
)}
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h3 className={`${isSlightlyLarger ? 'text-lg' : 'text-base'} font-semibold text-gray-900 dark:text-white`}> <h3 className={`${isSlightlyLarger ? 'text-lg' : 'text-base'} font-semibold text-gray-900 dark:text-white`}>
{motor.name} {motor.name}
@@ -66,7 +82,7 @@ export default function MotorStep({ config, updateConfig }) {
)} )}
</div> </div>
<p className={`${isSlightlyLarger ? 'text-sm' : 'text-sm'} text-gray-600 dark:text-gray-300 mb-3`}>{motor.description}</p> <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> <div>
<span className="text-gray-500 dark:text-gray-400">Speed:</span>{' '} <span className="text-gray-500 dark:text-gray-400">Speed:</span>{' '}
<span className="font-medium text-gray-900 dark:text-white">{motor.speed}</span> <span className="font-medium text-gray-900 dark:text-white">{motor.speed}</span>
@@ -80,26 +96,33 @@ export default function MotorStep({ config, updateConfig }) {
<span className="font-medium text-gray-900 dark:text-white">{motor.gear_count}</span> <span className="font-medium text-gray-900 dark:text-white">{motor.gear_count}</span>
</div> </div>
</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 && ( {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={`${isSlightlyLarger ? 'mb-3' : 'mb-2'} flex items-center justify-between`}>
<div className="flex flex-wrap gap-2"> <div className={`${isSlightlyLarger ? 'text-lg' : 'text-lg'} font-bold text-blue-600 dark:text-blue-400`}>
{motorPrices[motor.id] || '...'}
</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) => ( {motor.links.map((link, index) => (
<a <a
key={index} key={index}
href={link.link} href={link.link}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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()} 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 <svg
className="w-3 h-3 mr-1.5" className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -111,11 +134,31 @@ export default function MotorStep({ config, updateConfig }) {
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/> />
</svg> </svg>
{link.store} </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> </a>
))} ))}
</div> </div>
</div> </div>
</>
)} )}
</button> </button>
); );
@@ -129,16 +172,16 @@ export default function MotorStep({ config, updateConfig }) {
{/* Recommended Motor(s) */} {/* Recommended Motor(s) */}
{recommendedMotors.length > 0 && ( {recommendedMotors.length > 0 && (
<div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center' : ''}`}> <div className={`mb-8 ${hasSingleRecommended ? 'flex justify-center w-full' : ''}`}>
{hasSingleRecommended ? ( {hasSingleRecommended ? (
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{renderMotorCard(recommendedMotors[0], true, true)} {renderMotorCard(recommendedMotors[0], true)}
</div> </div>
) : ( ) : (
<div> <div>
<h3 className="text-lg font-semibold mb-4 text-gray-700 dark:text-gray-300">Recommended Options</h3> <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"> <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>
</div> </div>
)} )}
@@ -150,10 +193,19 @@ export default function MotorStep({ config, updateConfig }) {
<div> <div>
<h3 className="text-lg font-semibold mb-4 text-gray-700 dark:text-gray-300">Other Options</h3> <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"> <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> </div>
)} )}
</div> </div>
); );
} }
MotorStep.propTypes = {
config: PropTypes.shape({
motor: PropTypes.shape({
id: PropTypes.string,
}),
}).isRequired,
updateConfig: PropTypes.func.isRequired,
};

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import partsData from '../../data/index.js'; import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat'; import { formatPrice } from '../../utils/priceFormat';
import ImageWithFallback from '../ui/ImageWithFallback';
export default function OptionsStep({ config, updateConfig, buildType }) { export default function OptionsStep({ config, updateConfig, buildType }) {
const [expandedMainSections, setExpandedMainSections] = useState({}); const [expandedMainSections, setExpandedMainSections] = useState({});
@@ -167,24 +168,17 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
<button <button
key={option.id} key={option.id}
onClick={() => handleOptionClick(option, mainSectionId, subSectionId)} onClick={() => handleOptionClick(option, mainSectionId, subSectionId)}
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${ className={`p-4 border-2 rounded-lg text-left transition-all w-full ${isSelected
isSelected
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30' ? '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' : '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 && ( <ImageWithFallback
<div className="mb-3 flex justify-center">
<img
src={option.image} src={option.image}
alt={option.name} alt={option.name}
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700" className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
onError={(e) => { containerClassName="mb-3 flex justify-center"
e.target.style.display = 'none';
}}
/> />
</div>
)}
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-white mb-1"> <h4 className="font-semibold text-gray-900 dark:text-white mb-1">
@@ -274,8 +268,7 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
)} )}
</div> </div>
<svg <svg
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${ className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${isExpanded ? 'transform rotate-180' : ''
isExpanded ? 'transform rotate-180' : ''
}`} }`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -308,20 +301,17 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
const isComplete = isMainSectionComplete(mainSectionId, mainSection); const isComplete = isMainSectionComplete(mainSectionId, mainSection);
return ( return (
<div key={mainSectionId} className={`border-2 rounded-lg overflow-hidden mb-4 ${ <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'
isComplete ? 'border-green-500 dark:border-green-600' : 'border-gray-300 dark:border-gray-700'
}`}> }`}>
<button <button
onClick={() => toggleMainSection(mainSectionId)} onClick={() => toggleMainSection(mainSectionId)}
className={`w-full px-6 py-4 transition-colors flex items-center justify-between ${ className={`w-full px-6 py-4 transition-colors flex items-center justify-between ${isComplete
isComplete
? 'bg-green-50 dark:bg-green-900/30 hover:bg-green-100 dark:hover:bg-green-900/40' ? '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' : 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className={`text-xl font-bold ${ <h3 className={`text-xl font-bold ${isComplete ? 'text-green-900 dark:text-green-300' : 'text-gray-900 dark:text-white'
isComplete ? 'text-green-900 dark:text-green-300' : 'text-gray-900 dark:text-white'
}`}> }`}>
{mainSection.title} {mainSection.title}
</h3> </h3>
@@ -347,8 +337,7 @@ export default function OptionsStep({ config, updateConfig, buildType }) {
)} )}
</div> </div>
<svg <svg
className={`w-6 h-6 transition-transform ${ className={`w-6 h-6 transition-transform ${isExpanded ? 'transform rotate-180' : ''
isExpanded ? 'transform rotate-180' : ''
} ${isComplete ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'}`} } ${isComplete ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"

View File

@@ -1,19 +1,42 @@
import { useState, useEffect, useMemo } from 'react';
import partsData from '../../data/index.js'; 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 }) { export default function PowerSupplyStep({ config, updateConfig }) {
const selectedPowerSupplyId = config.powerSupply?.id; const selectedPowerSupplyId = config.powerSupply?.id;
const selectedMotorId = config.motor?.id; const selectedMotorId = config.motor?.id;
const { currency, exchangeRates } = useCurrency();
const [psuPrices, setPsuPrices] = useState({});
const handleSelect = (powerSupply) => { const handleSelect = (powerSupply) => {
updateConfig({ powerSupply }); updateConfig({ powerSupply });
}; };
// Filter compatible power supplies // Filter compatible power supplies
const compatiblePowerSupplies = partsData.powerSupplies.filter((psu) => { const compatiblePowerSupplies = useMemo(() => {
return partsData.powerSupplies.filter((psu) => {
if (!selectedMotorId) return true; if (!selectedMotorId) return true;
return psu.compatibleMotors.includes(selectedMotorId); 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 ( return (
<div> <div>
@@ -38,24 +61,17 @@ export default function PowerSupplyStep({ config, updateConfig }) {
<button <button
key={powerSupply.id} key={powerSupply.id}
onClick={() => handleSelect(powerSupply)} onClick={() => handleSelect(powerSupply)}
className={`p-6 border-2 rounded-lg text-left transition-all ${ className={`w-full p-6 border-2 rounded-lg text-left transition-all ${selectedPowerSupplyId === powerSupply.id
selectedPowerSupplyId === powerSupply.id
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30' ? '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' : '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 && ( <ImageWithFallback
<div className="mb-4 flex justify-center">
<img
src={powerSupply.image} src={powerSupply.image}
alt={powerSupply.name} alt={powerSupply.name}
className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700" className="h-32 w-32 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
onError={(e) => { containerClassName="mb-4 flex justify-center"
e.target.style.display = 'none';
}}
/> />
</div>
)}
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{powerSupply.name} {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"> <p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
{powerSupply.description} {powerSupply.description}
</p> </p>
<div className="flex gap-4 text-sm"> <div className="flex gap-4 text-sm mb-3">
<div> <div>
<span className="text-gray-500 dark:text-gray-400">Voltage:</span>{' '} <span className="text-gray-500 dark:text-gray-400">Voltage:</span>{' '}
<span className="font-medium text-gray-900 dark:text-white">{powerSupply.voltage}</span> <span className="font-medium text-gray-900 dark:text-white">{powerSupply.voltage}</span>
@@ -91,26 +107,33 @@ export default function PowerSupplyStep({ config, updateConfig }) {
<span className="font-medium text-gray-900 dark:text-white">{powerSupply.current}</span> <span className="font-medium text-gray-900 dark:text-white">{powerSupply.current}</span>
</div> </div>
</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 && ( {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="mb-3 flex items-center justify-between">
<div className="flex flex-wrap gap-2"> <div className="text-lg font-bold text-blue-600 dark:text-blue-400">
{psuPrices[powerSupply.id] || '...'}
</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) => ( {powerSupply.links.map((link, index) => (
<a <a
key={index} key={index}
href={link.link} href={link.link}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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()} 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 <svg
className="w-3 h-3 mr-1.5" className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -122,11 +145,31 @@ export default function PowerSupplyStep({ config, updateConfig }) {
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/> />
</svg> </svg>
{link.store} </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> </a>
))} ))}
</div> </div>
</div> </div>
</>
)} )}
</button> </button>
))} ))}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import partsData from '../../data/index.js'; import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat'; import { formatPrice } from '../../utils/priceFormat';
import ImageWithFallback from '../ui/ImageWithFallback';
export default function RemoteStep({ config, updateConfig, buildType }) { export default function RemoteStep({ config, updateConfig, buildType }) {
const [expandedKnobs, setExpandedKnobs] = useState(false); const [expandedKnobs, setExpandedKnobs] = useState(false);
@@ -88,24 +89,17 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
<button <button
key={remote.id} key={remote.id}
onClick={() => handleRemoteSelect(remote.id)} onClick={() => handleRemoteSelect(remote.id)}
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${ className={`p-4 border-2 rounded-lg text-left transition-all w-full ${isSelected
isSelected
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30' ? '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' : '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 && ( <ImageWithFallback
<div className="mb-3 flex justify-center">
<img
src={imagePath} src={imagePath}
alt={remote.name} alt={remote.name}
className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700" className="h-48 w-48 object-contain rounded-lg bg-gray-100 dark:bg-gray-700"
onError={(e) => { containerClassName="mb-3 flex justify-center"
e.target.style.display = 'none';
}}
/> />
</div>
)}
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-white mb-1"> <h4 className="font-semibold text-gray-900 dark:text-white mb-1">
@@ -147,8 +141,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
onClick={() => handlePCBSelect('rad')} onClick={() => handlePCBSelect('rad')}
className={`px-4 py-2 border-2 rounded-lg transition-all ${ className={`px-4 py-2 border-2 rounded-lg transition-all ${selectedRemotePCB === 'rad'
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-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' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`} }`}
@@ -157,8 +150,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
</button> </button>
<button <button
onClick={() => handlePCBSelect('pcbway')} onClick={() => handlePCBSelect('pcbway')}
className={`px-4 py-2 border-2 rounded-lg transition-all ${ className={`px-4 py-2 border-2 rounded-lg transition-all ${selectedRemotePCB === 'pcbway'
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-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' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`} }`}
@@ -177,8 +169,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
<button <button
key={knob.id} key={knob.id}
onClick={() => handleKnobSelect(knob)} onClick={() => handleKnobSelect(knob)}
className={`p-4 border-2 rounded-lg text-left transition-all w-full ${ className={`p-4 border-2 rounded-lg text-left transition-all w-full ${isSelected
isSelected
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/30' ? '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' : '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'
}`} }`}
@@ -243,7 +234,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
{/* Remote Selection */} {/* Remote Selection */}
<div className="mb-6"> <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"> <div className="grid grid-cols-2 gap-4">
{availableRemotesFiltered.map((remote) => renderRemoteCard(remote))} {availableRemotesFiltered.map((remote) => renderRemoteCard(remote))}
</div> </div>
@@ -285,8 +276,7 @@ export default function RemoteStep({ config, updateConfig, buildType }) {
)} )}
</div> </div>
<svg <svg
className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${ className={`w-4 h-4 text-gray-500 dark:text-gray-400 transition-transform ${expandedKnobs ? 'transform rotate-180' : ''
expandedKnobs ? 'transform rotate-180' : ''
}`} }`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import partsData from '../../data/index.js'; import partsData from '../../data/index.js';
import { formatPrice } from '../../utils/priceFormat'; import { formatPrice } from '../../utils/priceFormat';
import ImageWithFallback from '../ui/ImageWithFallback';
export default function ToyMountStep({ config, updateConfig }) { export default function ToyMountStep({ config, updateConfig }) {
const [expandedSubSections, setExpandedSubSections] = useState({}); const [expandedSubSections, setExpandedSubSections] = useState({});
@@ -56,18 +57,13 @@ export default function ToyMountStep({ config, updateConfig }) {
}`} }`}
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{option.image && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<img <ImageWithFallback
src={option.image} src={option.image}
alt={option.name} alt={option.name}
className="h-24 w-24 object-contain rounded-lg bg-gray-100 dark:bg-gray-700" 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>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex-1"> <div className="flex-1">

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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';

View 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>
);
};

View File

@@ -4,79 +4,172 @@
"id": "hardware-fasteners-m3x8-shcs", "id": "hardware-fasteners-m3x8-shcs",
"name": "M3x8 SHCS", "name": "M3x8 SHCS",
"description": "Hardware fasteners m3x8 socket head cap screw", "description": "Hardware fasteners m3x8 socket head cap screw",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M3x16 Socket Head cap Screw": { "M3x16 Socket Head cap Screw": {
"id": "hardware-fasteners-m3x16-shcs", "id": "hardware-fasteners-m3x16-shcs",
"name": "M3x16 SHCS", "name": "M3x16 SHCS",
"description": "Hardware fasteners m3x16 socket head cap screw", "description": "Hardware fasteners m3x16 socket head cap screw",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M3x20 Socket Head cap Screw": { "M3x20 Socket Head cap Screw": {
"id": "hardware-fasteners-m3x20-shcs", "id": "hardware-fasteners-m3x20-shcs",
"name": "M3x20 SHCS", "name": "M3x20 SHCS",
"description": "m3x20 socket head cap screw", "description": "m3x20 socket head cap screw",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M3 Hex Nut": { "M3 Hex Nut": {
"id": "hardware-fasteners-m3-hex-nut", "id": "hardware-fasteners-m3-hex-nut",
"name": "M3 Hex Nut", "name": "M3 Hex Nut",
"description": "Hardware fasteners m3 hex nut", "description": "Hardware fasteners m3 hex nut",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M5 Hex Nut": { "M4x10 Socket Head cap Screw": {
"id": "hardware-fasteners-m5-hex-nut", "id": "hardware-fasteners-m4x10-shcs",
"name": "M5 Hex Nut", "name": "M4x10 SHCS",
"description": "Hardware fasteners m5 hex nut", "description": "Hardware fasteners m4x10 socket head cap screw",
"price": 0 "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": { "M5x20 Socket Head cap Screw": {
"id": "hardware-fasteners-m5x20-shcs", "id": "hardware-fasteners-m5x20-shcs",
"name": "M5x20 SHCS", "name": "M5x20 SHCS",
"description": "Hardware fasteners m5x20 socket head cap screw", "description": "Hardware fasteners m5x20 socket head cap screw",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M5x35 Socket Head cap Screw": { "M5x35 Socket Head cap Screw": {
"id": "hardware-fasteners-m5x35-shcs", "id": "hardware-fasteners-m5x35-shcs",
"name": "M5x35 SHCS", "name": "M5x35 SHCS",
"description": "Hardware fasteners m5x35 socket head cap screw", "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": { "M5x20mm Hex Coupling Nut": {
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut", "id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
"name": "M5x20mm Hex Coupling Nut", "name": "M5x20mm Hex Coupling Nut",
"description": "Hardware fasteners m5x20mm hex coupling nut", "description": "Hardware fasteners m5x20mm hex coupling nut",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M6x12 Socket Head cap Screw": { "M6x12 Socket Head cap Screw": {
"id": "hardware-fasteners-m6x12-shcs", "id": "hardware-fasteners-m6x12-shcs",
"name": "M6x12 SHCS", "name": "M6x12 SHCS",
"description": "Hardware fasteners m6x12 socket head cap screw", "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": { "M6x25 Socket Head cap Screw": {
"id": "hardware-fasteners-m6x25-shcs", "id": "hardware-fasteners-m6x25-shcs",
"name": "M6x25 SHCS", "name": "M6x25 SHCS",
"description": "Hardware fasteners m6x25 socket head cap screw", "description": "Hardware fasteners m6x25 socket head cap screw",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M6 T Nuts": { "M6 T Nuts": {
"id": "hardware-fasteners-m6-t-nuts", "id": "hardware-fasteners-m6-t-nuts",
"name": "M6 T Nuts", "name": "M6 T Nuts",
"description": "Hardware fasteners m6 t nuts", "description": "Hardware fasteners m6 t nuts",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M6 Washer": { "M6 Washer": {
"id": "hardware-fasteners-m6-washer", "id": "hardware-fasteners-m6-washer",
"name": "M6 Washer", "name": "M6 Washer",
"description": "Hardware fasteners m6 washer", "description": "Hardware fasteners m6 washer",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"M6x25 Handle": { "M6x25 Handle": {
"id": "hardware-fasteners-m6x25-handle", "id": "hardware-fasteners-m6x25-handle",
"name": "M6x25 Handle", "name": "M6x25 Handle",
"description": "Hardware fasteners m6x25 handle", "description": "Hardware fasteners m6x25 handle",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
} }
}, },
"motionComponents": { "motionComponents": {
@@ -84,25 +177,37 @@
"id": "hardware-gt2-pulley", "id": "hardware-gt2-pulley",
"name": "GT2 Pulley", "name": "GT2 Pulley",
"description": "8mm Bore, 20T, 10mm Wide", "description": "8mm Bore, 20T, 10mm Wide",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"GT2 Belt": { "GT2 Belt": {
"id": "hardware-gt2-belt", "id": "hardware-gt2-belt",
"name": "GT2 Belt", "name": "GT2 Belt",
"description": "10mm wide, 500mm long", "description": "10mm wide, 500mm long",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"MGN12H Linear Rail": { "MGN12H Linear Rail": {
"id": "hardware-mgn12h-linear-rail", "id": "hardware-mgn12h-linear-rail",
"name": "MGN12H Linear Rail", "name": "MGN12H Linear Rail",
"description": "MGN12H Linear Rail, 350mm long [Min 250mm, recommended 350mm, Max 550mm]", "description": "MGN12H Linear Rail, 350mm long [Min 250mm, recommended 350mm, Max 550mm]",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"Bearing MR115-2RS": { "Bearing MR115-2RS": {
"id": "hardware-bearing-MR115-2RS 5x11x4mm", "id": "hardware-bearing-MR115-2RS 5x11x4mm",
"name": "Bearing MR115-2RS 5x11x4mm", "name": "Bearing MR115-2RS 5x11x4mm",
"description": "MR115-2RS 5x11x4mm", "description": "MR115-2RS 5x11x4mm",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
} }
}, },
"extrusions": { "extrusions": {
@@ -110,7 +215,10 @@
"id": "hardware-fasteners-3030-90-degree-support", "id": "hardware-fasteners-3030-90-degree-support",
"name": "3030 90 Degree Support", "name": "3030 90 Degree Support",
"description": "Hardware fasteners 3030 90 degree support", "description": "Hardware fasteners 3030 90 degree support",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
} }
}, },
"other": { "other": {
@@ -118,31 +226,46 @@
"id": "remote-hardware", "id": "remote-hardware",
"name": "Remote Hardware", "name": "Remote Hardware",
"description": "Remote hardware", "description": "Remote hardware",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"PitClamp Hardware": { "PitClamp Hardware": {
"id": "pitclamp-hardware", "id": "pitclamp-hardware",
"name": "PitClamp Hardware", "name": "PitClamp Hardware",
"description": "PitClamp hardware", "description": "PitClamp hardware",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"PitClamp Reinforced 3030 Hardware": { "PitClamp Reinforced 3030 Hardware": {
"id": "pitclamp-reinforced-3030-hardware", "id": "pitclamp-reinforced-3030-hardware",
"name": "PitClamp Reinforced 3030 Hardware", "name": "PitClamp Reinforced 3030 Hardware",
"description": "Hardware for PitClamp Reinforced 3030 hinges", "description": "Hardware for PitClamp Reinforced 3030 hinges",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"Middle Pivot Hardware": { "Middle Pivot Hardware": {
"id": "middle-pivot-hardware", "id": "middle-pivot-hardware",
"name": "Middle Pivot Hardware", "name": "Middle Pivot Hardware",
"description": "Middle Pivot hardware", "description": "Middle Pivot hardware",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
}, },
"Toy Mount Hardware": { "Toy Mount Hardware": {
"id": "toy-mount-hardware", "id": "toy-mount-hardware",
"name": "Toy Mount Hardware", "name": "Toy Mount Hardware",
"description": "Toy mount hardware", "description": "Toy mount hardware",
"price": 0 "price": {
"amount": 0,
"currency": "USD"
}
} }
} }
} }

View File

@@ -11,8 +11,18 @@
"timeEstimate": "2h14m", "timeEstimate": "2h14m",
"colour": "primary", "colour": "primary",
"required": true, "required": true,
"quantity": 1,
"filePath": "OSSM - Actuator Body Bottom.stl", "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", "id": "ossm-actuator-body-middle",
@@ -22,8 +32,18 @@
"timeEstimate": "2h23m", "timeEstimate": "2h23m",
"colour": "primary", "colour": "primary",
"required": true, "required": true,
"quantity": 1,
"filePath": "OSSM - Actuator Body Middle.stl", "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", "id": "ossm-actuator-body-cover",
@@ -33,8 +53,21 @@
"timeEstimate": "1h3m", "timeEstimate": "1h3m",
"colour": "primary", "colour": "primary",
"required": true, "required": true,
"quantity": 1,
"Condition": {
"cover.id": "standard-cover"
},
"filePath": "OSSM - Actuator Body Cover.stl", "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", "id": "ossm-belt-tensioner",
@@ -44,8 +77,18 @@
"timeEstimate": "40m25s", "timeEstimate": "40m25s",
"colour": "secondary", "colour": "secondary",
"required": true, "required": true,
"quantity": 1,
"filePath": "OSSM - Belt Tensioner.stl", "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", "id": "ossm-24mm-clamping-thread-belt-clamp",
@@ -55,8 +98,18 @@
"timeEstimate": "19m36s", "timeEstimate": "19m36s",
"colour": "secondary", "colour": "secondary",
"required": true, "required": true,
"quantity": 1,
"filePath": "OSSM - 24mm Clamping Thread Belt Clamp.stl", "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", "id": "ossm-24mm-clamping-thread-end-effector",
@@ -66,8 +119,18 @@
"timeEstimate": "1h20m", "timeEstimate": "1h20m",
"colour": "secondary", "colour": "secondary",
"required": true, "required": true,
"quantity": 1,
"filePath": "OSSM - 24mm Clamping Thread End Effector.stl", "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", "id": "ossm-24mm-nut-5-sided",
@@ -77,8 +140,18 @@
"timeEstimate": "21m10s", "timeEstimate": "21m10s",
"colour": "secondary", "colour": "secondary",
"required": true, "required": true,
"quantity": 2,
"filePath": "OSSM - 24mm Nut 5 Sided.stl", "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": [ "hardwareParts": [
@@ -87,7 +160,8 @@
"required": true, "required": true,
"quantity": 8, "quantity": 8,
"relatedParts": [ "relatedParts": [
"ossm-actuator-body-bottom" "ossm-actuator-body-bottom",
"ossm-actuator-body-cover"
] ]
}, },
{ {
@@ -95,7 +169,7 @@
"required": true, "required": true,
"quantity": 2, "quantity": 2,
"relatedParts": [ "relatedParts": [
"ossm-24mm-nut-6-sided" "ossm-24mm-clamping-thread-end-effector"
] ]
}, },
{ {
@@ -111,11 +185,11 @@
"required": true, "required": true,
"quantity": 7, "quantity": 7,
"relatedParts": [ "relatedParts": [
"ossm-24mm-nut-hex" "ossm-24mm-nut-5-sided"
] ]
}, },
{ {
"id": "hardware-fasteners-m5-hex-nut", "id": "hardware-fasteners-m5-hex-nuts",
"required": true, "required": true,
"quantity": 7, "quantity": 7,
"relatedParts": [ "relatedParts": [
@@ -128,24 +202,15 @@
"quantity": 7, "quantity": 7,
"relatedParts": [ "relatedParts": [
"ossm-actuator-body-bottom", "ossm-actuator-body-bottom",
"ossm-actuator-body-middle", "ossm-actuator-body-middle"
"ossm-actuator-body-middle-pivot"
] ]
}, },
{ {
"id": "hardware-fasteners-m5x35-shcs", "id": "hardware-fasteners-m5x35-shcs",
"required": true, "required": true,
"quantity": 7, "quantity": 4,
"relatedParts": [ "relatedParts": [
"ossm-24mm-nut-shcs" "ossm-actuator-body-middle-pivot"
]
},
{
"id": "hardware-fasteners-m5x20mm-hex-coupling-nut",
"required": true,
"quantity": 7,
"relatedParts": [
"ossm-24mm-nut-hex"
] ]
}, },
{ {
@@ -170,8 +235,10 @@
"required": true, "required": true,
"quantity": 1, "quantity": 1,
"relatedParts": [ "relatedParts": [
"ossm-gt2-belt-clamp", "ossm-24mm-clamping-thread-belt-clamp",
"ossm-24mm-nut-shcs", "ossm-24mm-nut-5-sided",
"ossm-belt-tensioner",
"ossm-24mm-clamping-thread-end-effector",
"ossm-actuator-body-bottom" "ossm-actuator-body-bottom"
] ]
}, },

View 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"
}
]
}
]

View File

@@ -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
}
]
}
}

View File

@@ -0,0 +1,11 @@
import ossm from './ossm.json';
export default {
"mounting": {
"category": "Mounting",
"type": "base",
"systems": {
...ossm,
// ...armpitmfg
}
}
};

View 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"
}
}
]
}
}

View File

@@ -1,75 +1,21 @@
{ [
"3030-mount": {
"category": "PCB Mount",
"type": "base",
"printedParts": [
{ {
"id": "ossm-pcb-3030-mount", "id": "ossm-v2-pcb",
"name": "PCB 3030 Mount", "name": "OSSM V2.3 PCB",
"description": "PCB mount for 3030 extrusion", "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.",
"filamentEstimate": 15, "image": "/images/pcb/ossm-v2-pcb.png",
"timeEstimate": "45m",
"colour": "primary",
"required": true, "required": true,
"filePath": "OSSM - PCB - 3030 Mount.stl", "recommended": true,
"url": "https://github.com/KinkyMakers/OSSM-hardware/blob/main/Printed%20Parts/PCB/OSSM%20-%20PCB%20-%203030%20Mount.stl?raw=true" "links": [
{
"store": "Research & Desire",
"link": "https://www.researchanddesire.com/products/ossm-pcb-only",
"price": {
"amount": 83.20,
"currency": "CAD"
}, },
{ "updated": "2026-01-10"
"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"
]
} }
] ]
} }
} ]

View 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
}
]
}
}

View 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
}
}
};

View 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"
]
}
]
}
}

View 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"
}
]
}
]

View File

@@ -17,7 +17,16 @@
"colour": "primary", "colour": "primary",
"required": true, "required": true,
"filePath": "ossm-remote-body.stl", "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", "id": "ossm-remote-top-cover",
@@ -28,7 +37,16 @@
"colour": "secondary", "colour": "secondary",
"required": true, "required": true,
"filePath": "ossm-remote-top-cover.stl", "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": [ "knobs": [
@@ -41,7 +59,16 @@
"colour": "primary", "colour": "primary",
"required": true, "required": true,
"filePath": "ossm-remote-knob.stl", "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", "id": "ossm-remote-knob-simple",
@@ -52,7 +79,16 @@
"colour": "primary", "colour": "primary",
"required": true, "required": true,
"filePath": "ossm-remote-knob-simple.stl", "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", "id": "ossm-remote-knob-simple-with-position-indicator",
@@ -62,7 +98,16 @@
"colour": "primary", "colour": "primary",
"required": false, "required": false,
"filePath": "ossm-remote-knob-simple-with-position-indicator.stl", "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", "id": "ossm-remote-knob-knurled",
@@ -72,7 +117,16 @@
"colour": "primary", "colour": "primary",
"required": false, "required": false,
"filePath": "ossm-remote-knob-knurled.stl", "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", "id": "ossm-remote-knob-knurled-with-position-indicator",
@@ -82,7 +136,16 @@
"colour": "primary", "colour": "primary",
"required": false, "required": false,
"filePath": "ossm-remote-knob-knurled-with-position-indicator.stl", "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": [ "hardwareParts": [

View File

@@ -8,7 +8,10 @@
"description": "Pivot plate for the stand", "description": "Pivot plate for the stand",
"image": "/images/options/pivot-plate.webp", "image": "/images/options/pivot-plate.webp",
"hardwareCost": 10, "hardwareCost": 10,
"price": 0, "price": {
"amount": 0,
"currency": "USD"
},
"printedParts": [ "printedParts": [
{ {
"id": "pivot-plate", "id": "pivot-plate",
@@ -19,7 +22,16 @@
"required": true, "required": true,
"filePath": "OSSM - Stand - Pivot Plate.stl", "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", "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", "id": "pivot-plate-right",
@@ -30,7 +42,16 @@
"required": true, "required": true,
"filePath": "OSSM - Stand - Pivot Plate.stl", "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", "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", "id": "handle-spacer",
@@ -41,7 +62,16 @@
"required": true, "required": true,
"filePath": "OSSM - Stand - Pivot Spacer.stl", "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", "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": [ "hardwareParts": [
@@ -72,7 +102,10 @@
"description": "Reinforced 3030 hinges for PitClamp", "description": "Reinforced 3030 hinges for PitClamp",
"image": "/images/options/pitclamp-reinforced-3030-hinges.jpg", "image": "/images/options/pitclamp-reinforced-3030-hinges.jpg",
"hardwareCost": 15, "hardwareCost": 15,
"price": 0, "price": {
"amount": 0,
"currency": "USD"
},
"printedParts": [ "printedParts": [
{ {
"id": "pitclamp-reinforced-3030", "id": "pitclamp-reinforced-3030",
@@ -104,7 +137,10 @@
"filamentEstimate": 50, "filamentEstimate": 50,
"image": "/images/options/standard-feet.jpg", "image": "/images/options/standard-feet.jpg",
"hardwareCost": 0, "hardwareCost": 0,
"price": 0, "price": {
"amount": 0,
"currency": "USD"
},
"colour": "secondary", "colour": "secondary",
"required": true "required": true
}, },
@@ -115,7 +151,10 @@
"filamentEstimate": 60, "filamentEstimate": 60,
"image": "/images/options/suction-feet.jpg", "image": "/images/options/suction-feet.jpg",
"hardwareCost": 5, "hardwareCost": 5,
"price": 0, "price": {
"amount": 0,
"currency": "USD"
},
"colour": "secondary", "colour": "secondary",
"required": true "required": true
} }
@@ -153,7 +192,16 @@
"required": true, "required": true,
"filePath": "OSSM - 3030 Cap.stl", "filePath": "OSSM - 3030 Cap.stl",
"quantity": 6, "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, "filamentEstimate": 0,
"image": "/images/options/standard-90-degree-support.jpg", "image": "/images/options/standard-90-degree-support.jpg",
"hardwareCost": 10, "hardwareCost": 10,
"price": "$10.00-$20.00", "price": {
"amount": {
"min": 10.00,
"max": 20.00
},
"currency": "USD"
},
"colour": "primary", "colour": "primary",
"required": true, "required": true,
"isHardwareOnly": true "isHardwareOnly": true
@@ -180,7 +234,13 @@
"filamentEstimate": 100, "filamentEstimate": 100,
"image": "/images/options/3d-printed-90-degree-support.jpg", "image": "/images/options/3d-printed-90-degree-support.jpg",
"hardwareCost": 2, "hardwareCost": 2,
"price": "$2.00-$4.00", "price": {
"amount": {
"min": 2.00,
"max": 4.00
},
"currency": "USD"
},
"colour": "secondary", "colour": "secondary",
"required": true "required": true
} }

View File

@@ -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": []
}
]
}
}

View 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
};

View 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": []
}
]
}

View 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
}
}
}
}

View File

@@ -1,14 +1,15 @@
import motors from './motors.json'; import motors from './components/motors.json';
import powerSupplies from './powerSupplies.json'; import powerSupplies from './components/powerSupplies.json';
import optionsData from './options.json'; import pcbs from './components/pcb.json';
import colors from './colors.json'; import optionsData from './config/options.json';
import hardwareData from './hardware.json'; import colors from './common/colors.json';
import hardwareData from './common/hardware.json';
import actuatorComponents from './components/actuator.json'; import actuatorComponents from './components/actuator.json';
import standComponents from './components/stand.json'; import standComponents from './components/stand.json';
import mountingComponents from './components/mounting.json'; import mountingComponents from './components/mounting/index.js';
import toyMountsComponents from './components/toyMounts.json'; import toyMountsComponents from './components/toyMounts/index.js';
import remoteComponents from './components/remote.json'; 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 // Create a hardware lookup map from hardware.json
const hardwareLookup = new Map(); const hardwareLookup = new Map();
@@ -245,6 +246,7 @@ const options = processOptions(optionsData, components);
export default { export default {
motors, motors,
powerSupplies, powerSupplies,
pcbs,
options, options,
colors, colors,
components, components,

View File

@@ -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
}
]

View File

@@ -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
}
}
}
}

View File

@@ -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"
}
]
}
]

View 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 };
}

View File

@@ -3,11 +3,18 @@ import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
import { ThemeProvider } from './contexts/ThemeContext' 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( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<CurrencyProvider>
<App /> <App />
</CurrencyProvider>
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
) )

View 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