diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..2038d89aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Claude +.claude/ +CLAUDE.md + +# macOS +.DS_Store +**/.DS_Store + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +venv/ +.venv/ +env/ +.env +*.egg-info/ +dist/ +build/ + +# Node +frontend/node_modules/ +npm-debug.log* + +# Database +*.db +*.db-journal + +# Archive files (user data) +archive/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Screenshots (development - root folder only) +/screenshots/ + +# Logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 000000000..039ea9dab --- /dev/null +++ b/README.md @@ -0,0 +1,429 @@ +# Bambusy + +

+ Bambusy Logo +

+ +

+ A self-hosted print archive and management system for Bambu Lab 3D printers +

+ +

+ Features • + Screenshots • + Installation • + Configuration • + Usage • + Contributing +

+ +--- + +## Features + +- **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (X1, X1C, P1P, P1S, A1, A1 Mini) +- **Automatic Print Archiving** - Automatically saves 3MF files when prints complete +- **3D Model Preview** - Interactive Three.js viewer for archived prints +- **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, and more +- **Print Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets + - Print success rates + - Filament usage trends + - Print activity calendar + - Cost tracking +- **Filament Cost Tracking** - Track costs per print with customizable filament database +- **Photo Attachments** - Attach photos to archived prints for documentation +- **Failure Analysis** - Document failed prints with notes and photos +- **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets +- **File Manager** - Browse and manage files on your printer's SD card +- **Re-print** - Send archived prints back to any connected printer +- **Dark/Light Theme** - Easy on the eyes, day or night +- **Keyboard Shortcuts** - Quick navigation with keyboard shortcuts + +## Screenshots + + +

+ Dashboard +
Customizable Dashboard with drag-and-drop widgets +

+ +

+ Printers +
Real-time printer monitoring +

+ +

+ Archives +
Print archive with grid and list views +

+ +

+ 3D Preview +
Interactive 3D model preview +

+ +## Requirements + +### System Requirements +- **Python 3.10+** (3.11 or 3.12 recommended) +- **Node.js 18+** (only needed if building frontend from source) +- **Git** (for cloning the repository) + +### Network Requirements +- Bambu Lab printer with **LAN Mode** enabled +- Printer and Bambusy server must be on the same local network +- Ports used: 8883 (MQTT/TLS), 990 (FTPS) + +### Supported Printers +- Bambu Lab X1 / X1 Carbon +- Bambu Lab P1P / P1S +- Bambu Lab A1 / A1 Mini + +## Installation + +### Option 1: Quick Install (Linux/macOS) + +```bash +# Clone the repository +git clone https://github.com/maziggy/bambusy.git +cd bambusy + +# Create and activate virtual environment +python3 -m venv venv +source venv/bin/activate + +# Install Python dependencies +pip install -r requirements.txt + +# Start the server +uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 +``` + +Open http://localhost:8000 in your browser. + +### Option 2: Detailed Install (All Platforms) + +#### Step 1: Install Prerequisites + +**macOS:** +```bash +# Install Homebrew if not installed +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Install Python and Node.js +brew install python@3.12 node +``` + +**Ubuntu/Debian:** +```bash +sudo apt update +sudo apt install python3 python3-venv python3-pip nodejs npm git +``` + +**Windows:** +1. Download and install [Python 3.12](https://www.python.org/downloads/) (check "Add to PATH") +2. Download and install [Node.js LTS](https://nodejs.org/) +3. Download and install [Git](https://git-scm.com/download/win) + +#### Step 2: Clone the Repository + +```bash +git clone https://github.com/maziggy/bambusy.git +cd bambusy +``` + +#### Step 3: Set Up Python Environment + +**Linux/macOS:** +```bash +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +``` + +**Windows (PowerShell):** +```powershell +python -m venv venv +.\venv\Scripts\Activate.ps1 +pip install --upgrade pip +pip install -r requirements.txt +``` + +**Windows (Command Prompt):** +```cmd +python -m venv venv +venv\Scripts\activate.bat +pip install --upgrade pip +pip install -r requirements.txt +``` + +#### Step 4: Build Frontend (Optional) + +The repository includes pre-built frontend files in `/static`. If you want to build from source: + +```bash +cd frontend +npm install +npm run build +cd .. +``` + +#### Step 5: Run the Application + +```bash +uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 +``` + +Open http://localhost:8000 in your browser. + +### Running as a Service (Linux) + +Create a systemd service for automatic startup: + +```bash +sudo nano /etc/systemd/system/bambusy.service +``` + +Add the following content (adjust paths as needed): + +```ini +[Unit] +Description=Bambusy Print Archive +After=network.target + +[Service] +Type=simple +User=YOUR_USERNAME +WorkingDirectory=/home/YOUR_USERNAME/bambusy +Environment="PATH=/home/YOUR_USERNAME/bambusy/venv/bin" +ExecStart=/home/YOUR_USERNAME/bambusy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start the service: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable bambusy +sudo systemctl start bambusy + +# Check status +sudo systemctl status bambusy + +# View logs +sudo journalctl -u bambusy -f +``` + +### Running with Docker (Coming Soon) + +```bash +docker run -d \ + --name bambusy \ + -p 8000:8000 \ + -v bambusy_data:/app/data \ + -v bambusy_archive:/app/archive \ + maziggy/bambusy:latest +``` + +### Updating Bambusy + +```bash +cd bambusy +git pull origin main + +# Activate virtual environment +source venv/bin/activate # Linux/macOS +# or: .\venv\Scripts\Activate.ps1 # Windows PowerShell + +# Update dependencies +pip install -r requirements.txt + +# Rebuild frontend (if needed) +cd frontend +npm install +npm run build +cd .. + +# Restart the application +``` + +## Configuration + +### Enabling LAN Mode on Your Printer + +To connect Bambusy to your printer, you need to enable LAN Mode: + +1. On your printer, go to **Settings** > **Network** > **LAN Mode** +2. Enable **LAN Mode** (this requires Developer Mode to be enabled first) +3. Note down the **Access Code** displayed +4. Find your printer's **IP Address** in network settings +5. Find your printer's **Serial Number** in device info + +### Adding a Printer in Bambusy + +1. Go to the **Printers** page +2. Click **Add Printer** +3. Enter: + - **Name**: A friendly name for your printer + - **IP Address**: Your printer's local IP + - **Access Code**: The code from LAN Mode settings + - **Serial Number**: Your printer's serial number +4. Click **Save** + +The printer should connect automatically and show real-time status. + +## Usage + +### Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `1` | Go to Printers | +| `2` | Go to Archives | +| `3` | Go to Statistics | +| `4` | Go to Cloud Profiles | +| `5` | Go to Settings | +| `?` | Show keyboard shortcuts | + +### Dashboard Widgets + +The statistics dashboard features draggable, resizable widgets: +- **Drag** widgets by the grip handle to reorder +- **Resize** widgets by clicking the resize icon (cycles through 1/4, 1/2, full width) +- **Hide** widgets by clicking the eye icon +- **Reset** layout using the Reset Layout button + +Your layout preferences are saved automatically. + +### Archiving Prints + +Prints are automatically archived when they complete. You can also: +- Manually add photos to any archive +- Add failure analysis notes for failed prints +- Re-print any archived 3MF to a connected printer +- Export archives for backup + +## Tech Stack + +- **Backend**: Python / FastAPI +- **Frontend**: React / TypeScript / Tailwind CSS +- **Database**: SQLite +- **3D Viewer**: Three.js +- **Printer Communication**: MQTT (TLS) + FTPS + +## Project Structure + +``` +bambusy/ +├── backend/ +│ └── app/ +│ ├── api/routes/ # API endpoints +│ ├── core/ # Config, database +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ └── services/ # Business logic (MQTT, FTP, etc.) +├── frontend/ # React application +├── static/ # Built frontend + images +├── archive/ # Stored 3MF files +└── bambusy.db # SQLite database +``` + +## API Documentation + +Once running, API documentation is available at: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## Troubleshooting + +### Printer won't connect + +1. **Check LAN Mode is enabled** on your printer (Settings > Network > LAN Mode) +2. **Verify the Access Code** - it changes when you toggle LAN Mode +3. **Check network connectivity** - printer and server must be on the same network +4. **Firewall issues** - ensure ports 8883 (MQTT) and 990 (FTPS) are not blocked +5. **Correct Serial Number** - find it in printer settings under Device Info + +### "Connection refused" errors + +- The printer may be in sleep mode - wake it up and try again +- Check if another application is connected (only one MQTT connection allowed) +- Restart the printer if issues persist + +### 3MF files not archiving automatically + +- Ensure the printer is connected (green status indicator) +- Check that the print completed successfully +- Look at the server logs for any error messages + +### Frontend not loading + +- Make sure you built the frontend: `cd frontend && npm run build` +- Check that the `/static` folder contains `index.html` and `/assets` +- Clear browser cache and hard refresh (Ctrl+Shift+R) + +### Database errors + +The SQLite database (`bambusy.db`) is created automatically. If you encounter issues: + +```bash +# Backup and reset database +mv bambusy.db bambusy.db.backup +# Restart the application - a new database will be created +``` + +### View server logs + +```bash +# If running directly +uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug + +# If running as systemd service +sudo journalctl -u bambusy -f +``` + +## Known Issues / Roadmap + +### Beta Limitations +- [ ] Docker support not yet available +- [ ] No user authentication (single-user only) +- [ ] Limited to local network printers + +### Planned Features +- [ ] Docker deployment +- [ ] Multi-user support with authentication +- [ ] Print queue management +- [ ] Timelapse video integration +- [ ] Mobile-responsive improvements +- [ ] Printer groups/organization + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- [Bambu Lab](https://bambulab.com/) for making great printers +- The reverse engineering community for documenting the Bambu Lab protocol +- All contributors and testers + +--- + +

+ Made with ❤️ for the 3D printing community +

diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py new file mode 100644 index 000000000..a264767bb --- /dev/null +++ b/backend/app/api/routes/archives.py @@ -0,0 +1,890 @@ +from pathlib import Path +import zipfile +import io + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request +from fastapi.responses import FileResponse, Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func + +from backend.app.core.config import settings +from backend.app.core.database import get_db +from backend.app.models.archive import PrintArchive +from backend.app.schemas.archive import ArchiveResponse, ArchiveUpdate, ArchiveStats +from backend.app.services.archive import ArchiveService + + +router = APIRouter(prefix="/archives", tags=["archives"]) + + +@router.get("/", response_model=list[ArchiveResponse]) +async def list_archives( + printer_id: int | None = None, + limit: int = 50, + offset: int = 0, + db: AsyncSession = Depends(get_db), +): + """List archived prints.""" + service = ArchiveService(db) + return await service.list_archives( + printer_id=printer_id, + limit=limit, + offset=offset, + ) + + +@router.get("/stats", response_model=ArchiveStats) +async def get_archive_stats(db: AsyncSession = Depends(get_db)): + """Get statistics across all archives.""" + # Total counts + total_result = await db.execute(select(func.count(PrintArchive.id))) + total_prints = total_result.scalar() or 0 + + successful_result = await db.execute( + select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed") + ) + successful_prints = successful_result.scalar() or 0 + + failed_result = await db.execute( + select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed") + ) + failed_prints = failed_result.scalar() or 0 + + # Totals + time_result = await db.execute( + select(func.sum(PrintArchive.print_time_seconds)) + ) + total_time = (time_result.scalar() or 0) / 3600 # Convert to hours + + filament_result = await db.execute( + select(func.sum(PrintArchive.filament_used_grams)) + ) + total_filament = filament_result.scalar() or 0 + + cost_result = await db.execute( + select(func.sum(PrintArchive.cost)) + ) + total_cost = cost_result.scalar() or 0 + + # By filament type (split comma-separated values for multi-material prints) + filament_type_result = await db.execute( + select(PrintArchive.filament_type) + .where(PrintArchive.filament_type.isnot(None)) + ) + prints_by_filament: dict[str, int] = {} + for (filament_types,) in filament_type_result.all(): + # Split by comma and count each type + for ftype in filament_types.split(","): + ftype = ftype.strip() + if ftype: + prints_by_filament[ftype] = prints_by_filament.get(ftype, 0) + 1 + + # By printer + printer_result = await db.execute( + select(PrintArchive.printer_id, func.count(PrintArchive.id)) + .group_by(PrintArchive.printer_id) + ) + prints_by_printer = {str(k): v for k, v in printer_result.all()} + + return ArchiveStats( + total_prints=total_prints, + successful_prints=successful_prints, + failed_prints=failed_prints, + total_print_time_hours=round(total_time, 1), + total_filament_grams=round(total_filament, 1), + total_cost=round(total_cost, 2), + prints_by_filament_type=prints_by_filament, + prints_by_printer=prints_by_printer, + ) + + +@router.get("/{archive_id}", response_model=ArchiveResponse) +async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get a specific archive.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + return archive + + +@router.patch("/{archive_id}", response_model=ArchiveResponse) +async def update_archive( + archive_id: int, + update_data: ArchiveUpdate, + db: AsyncSession = Depends(get_db), +): + """Update archive metadata (tags, notes, cost, is_favorite).""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + for field, value in update_data.model_dump(exclude_unset=True).items(): + setattr(archive, field, value) + + await db.commit() + await db.refresh(archive) + return archive + + +@router.post("/{archive_id}/favorite", response_model=ArchiveResponse) +async def toggle_favorite( + archive_id: int, + db: AsyncSession = Depends(get_db), +): + """Toggle favorite status for an archive.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + archive.is_favorite = not archive.is_favorite + await db.commit() + await db.refresh(archive) + return archive + + +@router.post("/{archive_id}/rescan", response_model=ArchiveResponse) +async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)): + """Rescan the 3MF file and update metadata.""" + from backend.app.services.archive import ThreeMFParser + + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "Archive file not found") + + # Parse the 3MF file + parser = ThreeMFParser(file_path) + metadata = parser.parse() + + # Update fields from metadata + if metadata.get("filament_type"): + archive.filament_type = metadata["filament_type"] + if metadata.get("filament_color"): + archive.filament_color = metadata["filament_color"] + if metadata.get("print_time_seconds"): + archive.print_time_seconds = metadata["print_time_seconds"] + if metadata.get("filament_used_grams"): + archive.filament_used_grams = metadata["filament_used_grams"] + if metadata.get("layer_height"): + archive.layer_height = metadata["layer_height"] + if metadata.get("nozzle_diameter"): + archive.nozzle_diameter = metadata["nozzle_diameter"] + if metadata.get("bed_temperature"): + archive.bed_temperature = metadata["bed_temperature"] + if metadata.get("nozzle_temperature"): + archive.nozzle_temperature = metadata["nozzle_temperature"] + if metadata.get("makerworld_url"): + archive.makerworld_url = metadata["makerworld_url"] + if metadata.get("designer"): + archive.designer = metadata["designer"] + + await db.commit() + await db.refresh(archive) + return archive + + +@router.post("/rescan-all") +async def rescan_all_archives(db: AsyncSession = Depends(get_db)): + """Rescan all archives and update their metadata.""" + from backend.app.services.archive import ThreeMFParser + + result = await db.execute(select(PrintArchive)) + archives = list(result.scalars().all()) + + updated = 0 + errors = [] + + for archive in archives: + try: + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + errors.append({"id": archive.id, "error": "File not found"}) + continue + + parser = ThreeMFParser(file_path) + metadata = parser.parse() + + if metadata.get("filament_type"): + archive.filament_type = metadata["filament_type"] + if metadata.get("filament_color"): + archive.filament_color = metadata["filament_color"] + if metadata.get("print_time_seconds"): + archive.print_time_seconds = metadata["print_time_seconds"] + if metadata.get("filament_used_grams"): + archive.filament_used_grams = metadata["filament_used_grams"] + if metadata.get("layer_height"): + archive.layer_height = metadata["layer_height"] + if metadata.get("nozzle_diameter"): + archive.nozzle_diameter = metadata["nozzle_diameter"] + if metadata.get("makerworld_url"): + archive.makerworld_url = metadata["makerworld_url"] + if metadata.get("designer"): + archive.designer = metadata["designer"] + + updated += 1 + except Exception as e: + errors.append({"id": archive.id, "error": str(e)}) + + await db.commit() + return {"updated": updated, "errors": errors} + + +@router.delete("/{archive_id}") +async def delete_archive(archive_id: int, db: AsyncSession = Depends(get_db)): + """Delete an archive.""" + service = ArchiveService(db) + if not await service.delete_archive(archive_id): + raise HTTPException(404, "Archive not found") + return {"status": "deleted"} + + +@router.get("/{archive_id}/download") +async def download_archive( + archive_id: int, + inline: bool = False, + db: AsyncSession = Depends(get_db), +): + """Download the 3MF file.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "File not found") + + # Use inline disposition to let browser/OS handle file association + content_disposition = "inline" if inline else "attachment" + + return FileResponse( + path=file_path, + filename=archive.filename, + media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + content_disposition_type=content_disposition, + ) + + +@router.get("/{archive_id}/file/{filename}") +async def download_archive_with_filename( + archive_id: int, + filename: str, + db: AsyncSession = Depends(get_db), +): + """Download the 3MF file with filename in URL (for Bambu Studio protocol).""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "File not found") + + return FileResponse( + path=file_path, + filename=archive.filename, + media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + ) + + +@router.get("/{archive_id}/thumbnail") +async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get the thumbnail image.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive or not archive.thumbnail_path: + raise HTTPException(404, "Thumbnail not found") + + thumb_path = settings.base_dir / archive.thumbnail_path + if not thumb_path.exists(): + raise HTTPException(404, "Thumbnail file not found") + + return FileResponse(path=thumb_path, media_type="image/png") + + +@router.get("/{archive_id}/timelapse") +async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get the timelapse video.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive or not archive.timelapse_path: + raise HTTPException(404, "Timelapse not found") + + timelapse_path = settings.base_dir / archive.timelapse_path + if not timelapse_path.exists(): + raise HTTPException(404, "Timelapse file not found") + + return FileResponse( + path=timelapse_path, + media_type="video/mp4", + filename=f"{archive.print_name or 'timelapse'}.mp4", + ) + + +@router.post("/{archive_id}/timelapse/scan") +async def scan_timelapse( + archive_id: int, + db: AsyncSession = Depends(get_db), +): + """Scan printer for timelapse matching this archive and attach it.""" + from backend.app.models.printer import Printer + from backend.app.services.bambu_ftp import list_files_async, download_file_bytes_async + + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + if archive.timelapse_path: + return {"status": "exists", "message": "Timelapse already attached"} + + if not archive.printer_id: + raise HTTPException(400, "Archive has no associated printer") + + # Get printer + result = await db.execute(select(Printer).where(Printer.id == archive.printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + # Get base name from archive filename (without .3mf extension) + base_name = Path(archive.filename).stem + + # Scan timelapse directory on printer + try: + files = await list_files_async(printer.ip_address, printer.access_code, "/timelapse/video") + except Exception: + raise HTTPException(500, "Failed to connect to printer") + + # Look for matching timelapse + matching_file = None + mp4_files = [f for f in files if not f.get("is_directory") and f.get("name", "").endswith(".mp4")] + + # Strategy 1: Match by print name in filename + for f in mp4_files: + fname = f.get("name", "") + if base_name.lower() in fname.lower(): + matching_file = f + break + + # Strategy 2: Match by timestamp proximity + if not matching_file and (archive.started_at or archive.completed_at or archive.created_at): + import re + from datetime import datetime, timedelta + + archive_time = archive.started_at or archive.completed_at or archive.created_at + best_match = None + best_diff = timedelta(hours=24) # Max 24 hour difference + + for f in mp4_files: + fname = f.get("name", "") + # Parse timestamp from filename like "video_2025-11-24_03-17-40.mp4" + match = re.search(r'(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})', fname) + if match: + try: + file_time = datetime.strptime(match.group(1), "%Y-%m-%d_%H-%M-%S") + # Timelapse is usually created at print end, so compare to completed_at or created_at + compare_time = archive.completed_at or archive.created_at + if compare_time: + diff = abs(file_time - compare_time) + if diff < best_diff: + best_diff = diff + best_match = f + except ValueError: + continue + + if best_match and best_diff < timedelta(hours=2): # Within 2 hours + matching_file = best_match + + if not matching_file: + return {"status": "not_found", "message": "No matching timelapse found on printer"} + + # Download the timelapse + remote_path = f"/timelapse/video/{matching_file['name']}" + timelapse_data = await download_file_bytes_async( + printer.ip_address, printer.access_code, remote_path + ) + + if not timelapse_data: + raise HTTPException(500, "Failed to download timelapse") + + # Attach timelapse to archive + success = await service.attach_timelapse( + archive_id, timelapse_data, matching_file["name"] + ) + + if not success: + raise HTTPException(500, "Failed to attach timelapse") + + return { + "status": "attached", + "message": f"Timelapse '{matching_file['name']}' attached successfully", + "filename": matching_file["name"], + } + + +@router.post("/{archive_id}/timelapse/upload") +async def upload_timelapse( + archive_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + """Manually upload a timelapse video to an archive.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")): + raise HTTPException(400, "File must be a video file (.mp4, .avi, .mkv)") + + content = await file.read() + success = await service.attach_timelapse(archive_id, content, file.filename) + + if not success: + raise HTTPException(500, "Failed to attach timelapse") + + return {"status": "attached", "filename": file.filename} + + +# ============================================ +# Photo Endpoints +# ============================================ + +@router.post("/{archive_id}/photos") +async def upload_photo( + archive_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + """Upload a photo of the printed result.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")): + raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)") + + # Get archive directory + file_path = settings.base_dir / archive.file_path + archive_dir = file_path.parent + photos_dir = archive_dir / "photos" + photos_dir.mkdir(exist_ok=True) + + # Generate unique filename + import uuid + ext = Path(file.filename).suffix.lower() + photo_filename = f"{uuid.uuid4().hex[:8]}{ext}" + photo_path = photos_dir / photo_filename + + # Save file + content = await file.read() + photo_path.write_bytes(content) + + # Update archive photos list (create new list to trigger SQLAlchemy change detection) + photos = list(archive.photos or []) + photos.append(photo_filename) + archive.photos = photos + + await db.commit() + await db.refresh(archive) + + return {"status": "uploaded", "filename": photo_filename, "photos": archive.photos} + + +@router.get("/{archive_id}/photos/{filename}") +async def get_photo( + archive_id: int, + filename: str, + db: AsyncSession = Depends(get_db), +): + """Get a specific photo.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + photo_path = file_path.parent / "photos" / filename + + if not photo_path.exists(): + raise HTTPException(404, "Photo not found") + + # Determine media type + ext = Path(filename).suffix.lower() + media_types = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + } + media_type = media_types.get(ext, "image/jpeg") + + return FileResponse(path=photo_path, media_type=media_type) + + +@router.delete("/{archive_id}/photos/{filename}") +async def delete_photo( + archive_id: int, + filename: str, + db: AsyncSession = Depends(get_db), +): + """Delete a photo.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + if not archive.photos or filename not in archive.photos: + raise HTTPException(404, "Photo not found") + + # Delete file + file_path = settings.base_dir / archive.file_path + photo_path = file_path.parent / "photos" / filename + if photo_path.exists(): + photo_path.unlink() + + # Update archive photos list + photos = [p for p in archive.photos if p != filename] + archive.photos = photos if photos else None + + await db.commit() + + return {"status": "deleted", "photos": archive.photos} + + +# ============================================ +# QR Code Endpoint +# ============================================ + +@router.get("/{archive_id}/qrcode") +async def get_qrcode( + archive_id: int, + request: Request, + size: int = 200, + db: AsyncSession = Depends(get_db), +): + """Generate a QR code that links to this archive.""" + import qrcode + from qrcode.image.styledpil import StyledPilImage + + result = await db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + archive = result.scalar_one_or_none() + if not archive: + raise HTTPException(404, "Archive not found") + + # Build URL to archive detail page + base_url = str(request.base_url).rstrip('/') + archive_url = f"{base_url}/archives?id={archive_id}" + + # Generate QR code + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + ) + qr.add_data(archive_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Resize if needed + if size != 200: + img = img.resize((size, size)) + + # Convert to bytes + buffer = io.BytesIO() + img.save(buffer, format="PNG") + buffer.seek(0) + + return Response( + content=buffer.getvalue(), + media_type="image/png", + headers={ + "Content-Disposition": f'inline; filename="qr_{archive.print_name or archive_id}.png"' + } + ) + + +@router.get("/{archive_id}/capabilities") +async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)): + """Check what viewing capabilities are available for this 3MF file.""" + import json + import re + + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "File not found") + + has_model = False + has_gcode = False + build_volume = {"x": 256, "y": 256, "z": 256} # Default to X1/P1 size + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + names = zf.namelist() + + # Check for G-code + has_gcode = any(n.startswith('Metadata/') and n.endswith('.gcode') for n in names) + + # Check for 3D model - need to look for actual mesh data + for name in names: + if name.endswith('.model'): + try: + content = zf.read(name).decode('utf-8') + # Check if this model file contains actual mesh vertices + if '= 3: + # Get max X and Y from the corner coordinates + max_x = 0 + max_y = 0 + for coord in printable_area: + if 'x' in coord: + parts = coord.split('x') + if len(parts) == 2: + try: + x, y = int(parts[0]), int(parts[1]) + max_x = max(max_x, x) + max_y = max(max_y, y) + except ValueError: + pass + if max_x > 0 and max_y > 0: + build_volume["x"] = max_x + build_volume["y"] = max_y + + # Parse printable_height + printable_height = config_data.get('printable_height') + if printable_height: + try: + build_volume["z"] = int(printable_height) + except (ValueError, TypeError): + pass + except Exception: + pass + + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid 3MF file") + + return { + "has_model": has_model, + "has_gcode": has_gcode, + "build_volume": build_volume, + } + + +@router.get("/{archive_id}/gcode") +async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)): + """Extract and return G-code from the 3MF file.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "File not found") + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + # Bambu 3MF files store G-code in Metadata/plate_X.gcode + gcode_files = [n for n in zf.namelist() if n.startswith('Metadata/') and n.endswith('.gcode')] + if not gcode_files: + raise HTTPException( + 404, + "No G-code found. This file hasn't been sliced yet - G-code is only available after slicing in Bambu Studio." + ) + + # Get the first plate's G-code (usually plate_1.gcode) + gcode_content = zf.read(gcode_files[0]).decode('utf-8') + return Response(content=gcode_content, media_type="text/plain") + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid 3MF file") + except HTTPException: + raise + except Exception as e: + raise HTTPException(500, f"Error extracting G-code: {str(e)}") + + +@router.post("/upload") +async def upload_archive( + file: UploadFile = File(...), + printer_id: int | None = None, + db: AsyncSession = Depends(get_db), +): + """Manually upload a 3MF file to archive.""" + if not file.filename or not file.filename.endswith(".3mf"): + raise HTTPException(400, "File must be a .3mf file") + + # Save uploaded file temporarily + temp_path = settings.archive_dir / "temp" / file.filename + temp_path.parent.mkdir(parents=True, exist_ok=True) + + try: + content = await file.read() + temp_path.write_bytes(content) + + service = ArchiveService(db) + archive = await service.archive_print( + printer_id=printer_id, + source_file=temp_path, + ) + + if not archive: + raise HTTPException(400, "Failed to archive file") + + return ArchiveResponse.model_validate(archive) + finally: + if temp_path.exists(): + temp_path.unlink() + + +@router.post("/upload-bulk") +async def upload_archives_bulk( + files: list[UploadFile] = File(...), + printer_id: int | None = None, + db: AsyncSession = Depends(get_db), +): + """Bulk upload multiple 3MF files to archive.""" + results = [] + errors = [] + + for file in files: + if not file.filename or not file.filename.endswith(".3mf"): + errors.append({"filename": file.filename or "unknown", "error": "Not a .3mf file"}) + continue + + temp_path = settings.archive_dir / "temp" / file.filename + temp_path.parent.mkdir(parents=True, exist_ok=True) + + try: + content = await file.read() + temp_path.write_bytes(content) + + service = ArchiveService(db) + archive = await service.archive_print( + printer_id=printer_id, + source_file=temp_path, + ) + + if archive: + results.append({ + "filename": file.filename, + "id": archive.id, + "status": "success", + }) + else: + errors.append({"filename": file.filename, "error": "Failed to process"}) + except Exception as e: + errors.append({"filename": file.filename, "error": str(e)}) + finally: + if temp_path.exists(): + temp_path.unlink() + + return { + "uploaded": len(results), + "failed": len(errors), + "results": results, + "errors": errors, + } + + +@router.post("/{archive_id}/reprint") +async def reprint_archive( + archive_id: int, + printer_id: int, + db: AsyncSession = Depends(get_db), +): + """Send an archived 3MF file to a printer and start printing.""" + from backend.app.models.printer import Printer + from backend.app.services.bambu_ftp import upload_file_async + from backend.app.services.printer_manager import printer_manager + + # Get archive + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + # Get printer + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + # Check printer is connected + if not printer_manager.is_connected(printer_id): + raise HTTPException(400, "Printer is not connected") + + # Get the 3MF file path + file_path = settings.base_dir / archive.file_path + if not file_path.exists(): + raise HTTPException(404, "Archive file not found") + + # Upload file to printer via FTP + remote_filename = archive.filename + remote_path = f"/cache/{remote_filename}" + + uploaded = await upload_file_async( + printer.ip_address, + printer.access_code, + file_path, + remote_path, + ) + + if not uploaded: + raise HTTPException(500, "Failed to upload file to printer") + + # Start the print + started = printer_manager.start_print(printer_id, remote_filename) + + if not started: + raise HTTPException(500, "Failed to start print") + + return { + "status": "printing", + "printer_id": printer_id, + "archive_id": archive_id, + "filename": archive.filename, + } diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py new file mode 100644 index 000000000..76c39deb5 --- /dev/null +++ b/backend/app/api/routes/cloud.py @@ -0,0 +1,289 @@ +""" +Bambu Lab Cloud API Routes + +Handles authentication and profile management with Bambu Cloud. +""" + +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.database import get_db +from backend.app.models.settings import Settings +from backend.app.services.bambu_cloud import ( + get_cloud_service, + BambuCloudError, + BambuCloudAuthError, +) +from backend.app.schemas.cloud import ( + CloudLoginRequest, + CloudVerifyRequest, + CloudLoginResponse, + CloudAuthStatus, + CloudTokenRequest, + SlicerSettingsResponse, + SlicerSetting, + CloudDevice, +) + +router = APIRouter(prefix="/cloud", tags=["cloud"]) + + +# Keys for storing cloud credentials in settings +CLOUD_TOKEN_KEY = "bambu_cloud_token" +CLOUD_EMAIL_KEY = "bambu_cloud_email" + + +async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]: + """Get stored cloud token and email from database.""" + result = await db.execute( + select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])) + ) + settings = {s.key: s.value for s in result.scalars().all()} + return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY) + + +async def store_token(db: AsyncSession, token: str, email: str) -> None: + """Store cloud token and email in database.""" + for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]: + result = await db.execute(select(Settings).where(Settings.key == key)) + setting = result.scalar_one_or_none() + if setting: + setting.value = value + else: + db.add(Settings(key=key, value=value)) + await db.commit() + + +async def clear_token(db: AsyncSession) -> None: + """Clear stored cloud token and email.""" + result = await db.execute( + select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])) + ) + for setting in result.scalars().all(): + await db.delete(setting) + await db.commit() + + +@router.get("/status", response_model=CloudAuthStatus) +async def get_auth_status(db: AsyncSession = Depends(get_db)): + """Get current cloud authentication status.""" + token, email = await get_stored_token(db) + cloud = get_cloud_service() + + if token: + cloud.set_token(token) + + return CloudAuthStatus( + is_authenticated=cloud.is_authenticated, + email=email if cloud.is_authenticated else None, + ) + + +@router.post("/login", response_model=CloudLoginResponse) +async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)): + """ + Initiate login to Bambu Cloud. + + This will typically trigger a verification code to be sent to the user's email. + After receiving the code, call /cloud/verify to complete the login. + """ + cloud = get_cloud_service() + + # Store email temporarily for verification step + await store_token(db, "", request.email) + + try: + result = await cloud.login_request(request.email, request.password) + + if result.get("success") and cloud.access_token: + # Direct login succeeded (rare) + await store_token(db, cloud.access_token, request.email) + + return CloudLoginResponse( + success=result.get("success", False), + needs_verification=result.get("needs_verification", False), + message=result.get("message", "Unknown error"), + ) + except BambuCloudAuthError as e: + raise HTTPException(status_code=401, detail=str(e)) + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/verify", response_model=CloudLoginResponse) +async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)): + """ + Complete login with verification code. + + After calling /cloud/login, the user will receive an email with a 6-digit code. + Submit that code here to complete authentication. + """ + cloud = get_cloud_service() + + try: + result = await cloud.verify_code(request.email, request.code) + + if result.get("success") and cloud.access_token: + await store_token(db, cloud.access_token, request.email) + + return CloudLoginResponse( + success=result.get("success", False), + needs_verification=False, + message=result.get("message", "Unknown error"), + ) + except BambuCloudAuthError as e: + raise HTTPException(status_code=401, detail=str(e)) + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/token", response_model=CloudAuthStatus) +async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)): + """ + Set access token directly. + + For users who already have a token (e.g., from Bambu Studio). + """ + cloud = get_cloud_service() + cloud.set_token(request.access_token) + + # Verify token works by trying to get profile + try: + await cloud.get_user_profile() + await store_token(db, request.access_token, "token-auth") + return CloudAuthStatus(is_authenticated=True, email="token-auth") + except BambuCloudError: + cloud.logout() + raise HTTPException(status_code=401, detail="Invalid token") + + +@router.post("/logout") +async def logout(db: AsyncSession = Depends(get_db)): + """Log out of Bambu Cloud.""" + cloud = get_cloud_service() + cloud.logout() + await clear_token(db) + return {"success": True} + + +@router.get("/settings", response_model=SlicerSettingsResponse) +async def get_slicer_settings( + version: str = "01.09.00.00", + db: AsyncSession = Depends(get_db), +): + """ + Get all slicer settings (filament, printer, process presets). + + Requires authentication. + """ + token, _ = await get_stored_token(db) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + cloud = get_cloud_service() + cloud.set_token(token) + + if not cloud.is_authenticated: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + data = await cloud.get_slicer_settings(version) + + result = SlicerSettingsResponse() + + # Map API keys to our types (API uses 'print' for process presets) + type_mapping = { + "filament": "filament", + "printer": "printer", + "print": "process", # API calls it 'print', we call it 'process' + } + + for api_key, our_type in type_mapping.items(): + type_data = data.get(api_key, {}) + # Combine public and private presets, private (user's own) first + all_settings = type_data.get("private", []) + type_data.get("public", []) + + parsed = [] + for s in all_settings: + parsed.append(SlicerSetting( + setting_id=s.get("setting_id", s.get("id", "")), + name=s.get("name", "Unknown"), + type=our_type, + version=s.get("version"), + user_id=s.get("user_id"), + updated_time=s.get("updated_time"), + )) + setattr(result, our_type, parsed) + + return result + except BambuCloudAuthError: + await clear_token(db) + raise HTTPException(status_code=401, detail="Authentication expired") + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/settings/{setting_id}") +async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)): + """ + Get detailed information for a specific setting/preset. + + Returns the full preset configuration. + """ + token, _ = await get_stored_token(db) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + cloud = get_cloud_service() + cloud.set_token(token) + + if not cloud.is_authenticated: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + data = await cloud.get_setting_detail(setting_id) + return data + except BambuCloudAuthError: + await clear_token(db) + raise HTTPException(status_code=401, detail="Authentication expired") + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/devices", response_model=list[CloudDevice]) +async def get_devices(db: AsyncSession = Depends(get_db)): + """ + Get list of bound printer devices. + + Returns printers registered to the user's Bambu account. + """ + token, _ = await get_stored_token(db) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + cloud = get_cloud_service() + cloud.set_token(token) + + if not cloud.is_authenticated: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + data = await cloud.get_devices() + devices = data.get("devices", []) + + return [ + CloudDevice( + dev_id=d.get("dev_id", ""), + name=d.get("name", "Unknown"), + dev_model_name=d.get("dev_model_name"), + dev_product_name=d.get("dev_product_name"), + online=d.get("online", False), + ) + for d in devices + ] + except BambuCloudAuthError: + await clear_token(db) + raise HTTPException(status_code=401, detail="Authentication expired") + except BambuCloudError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/api/routes/filaments.py b/backend/app/api/routes/filaments.py new file mode 100644 index 000000000..845d5d4ee --- /dev/null +++ b/backend/app/api/routes/filaments.py @@ -0,0 +1,160 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.database import get_db +from backend.app.models.filament import Filament +from backend.app.schemas.filament import ( + FilamentCreate, + FilamentUpdate, + FilamentResponse, + FilamentCostCalculation, +) + + +router = APIRouter(prefix="/filaments", tags=["filaments"]) + + +@router.get("/", response_model=list[FilamentResponse]) +async def list_filaments(db: AsyncSession = Depends(get_db)): + """List all filaments.""" + result = await db.execute( + select(Filament).order_by(Filament.type, Filament.name) + ) + return list(result.scalars().all()) + + +@router.post("/", response_model=FilamentResponse) +async def create_filament( + filament_data: FilamentCreate, + db: AsyncSession = Depends(get_db), +): + """Create a new filament entry.""" + filament = Filament(**filament_data.model_dump()) + db.add(filament) + await db.commit() + await db.refresh(filament) + return filament + + +@router.get("/{filament_id}", response_model=FilamentResponse) +async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)): + """Get a specific filament.""" + result = await db.execute( + select(Filament).where(Filament.id == filament_id) + ) + filament = result.scalar_one_or_none() + if not filament: + raise HTTPException(404, "Filament not found") + return filament + + +@router.patch("/{filament_id}", response_model=FilamentResponse) +async def update_filament( + filament_id: int, + filament_data: FilamentUpdate, + db: AsyncSession = Depends(get_db), +): + """Update a filament.""" + result = await db.execute( + select(Filament).where(Filament.id == filament_id) + ) + filament = result.scalar_one_or_none() + if not filament: + raise HTTPException(404, "Filament not found") + + for field, value in filament_data.model_dump(exclude_unset=True).items(): + setattr(filament, field, value) + + await db.commit() + await db.refresh(filament) + return filament + + +@router.delete("/{filament_id}") +async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)): + """Delete a filament.""" + result = await db.execute( + select(Filament).where(Filament.id == filament_id) + ) + filament = result.scalar_one_or_none() + if not filament: + raise HTTPException(404, "Filament not found") + + await db.delete(filament) + await db.commit() + return {"status": "deleted"} + + +@router.post("/calculate-cost", response_model=FilamentCostCalculation) +async def calculate_cost( + filament_id: int, + weight_grams: float, + db: AsyncSession = Depends(get_db), +): + """Calculate the cost for a given weight of filament.""" + result = await db.execute( + select(Filament).where(Filament.id == filament_id) + ) + filament = result.scalar_one_or_none() + if not filament: + raise HTTPException(404, "Filament not found") + + cost = (weight_grams / 1000) * filament.cost_per_kg + + return FilamentCostCalculation( + filament_id=filament.id, + filament_name=filament.name, + weight_grams=weight_grams, + cost=round(cost, 2), + currency=filament.currency, + ) + + +@router.get("/by-type/{filament_type}", response_model=list[FilamentResponse]) +async def get_filaments_by_type( + filament_type: str, + db: AsyncSession = Depends(get_db), +): + """Get all filaments of a specific type.""" + result = await db.execute( + select(Filament) + .where(Filament.type.ilike(f"%{filament_type}%")) + .order_by(Filament.name) + ) + return list(result.scalars().all()) + + +@router.post("/seed-defaults") +async def seed_default_filaments(db: AsyncSession = Depends(get_db)): + """Seed the database with common filament types.""" + defaults = [ + {"name": "Generic PLA", "type": "PLA", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 50, "bed_temp_max": 60, "density": 1.24}, + {"name": "Generic PETG", "type": "PETG", "cost_per_kg": 25.0, "print_temp_min": 230, "print_temp_max": 250, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27}, + {"name": "Generic ABS", "type": "ABS", "cost_per_kg": 22.0, "print_temp_min": 230, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.04}, + {"name": "Generic TPU", "type": "TPU", "cost_per_kg": 35.0, "print_temp_min": 220, "print_temp_max": 250, "bed_temp_min": 40, "bed_temp_max": 60, "density": 1.21}, + {"name": "Generic ASA", "type": "ASA", "cost_per_kg": 28.0, "print_temp_min": 240, "print_temp_max": 260, "bed_temp_min": 90, "bed_temp_max": 110, "density": 1.07}, + {"name": "Bambu PLA Basic", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 20.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24}, + {"name": "Bambu PLA Matte", "type": "PLA", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 190, "print_temp_max": 220, "bed_temp_min": 35, "bed_temp_max": 55, "density": 1.24}, + {"name": "Bambu PETG Basic", "type": "PETG", "brand": "Bambu Lab", "cost_per_kg": 25.0, "print_temp_min": 250, "print_temp_max": 270, "bed_temp_min": 70, "bed_temp_max": 80, "density": 1.27}, + {"name": "Bambu ABS", "type": "ABS", "brand": "Bambu Lab", "cost_per_kg": 30.0, "print_temp_min": 260, "print_temp_max": 280, "bed_temp_min": 90, "bed_temp_max": 100, "density": 1.04}, + ] + + created = 0 + for filament_data in defaults: + # Check if already exists + result = await db.execute( + select(Filament).where( + Filament.name == filament_data["name"], + Filament.type == filament_data["type"], + ) + ) + if result.scalar_one_or_none(): + continue + + filament = Filament(**filament_data) + db.add(filament) + created += 1 + + await db.commit() + return {"created": created, "message": f"Created {created} default filaments"} diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py new file mode 100644 index 000000000..bc67c8ce9 --- /dev/null +++ b/backend/app/api/routes/printers.py @@ -0,0 +1,412 @@ +import io +import logging +import zipfile +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException + +logger = logging.getLogger(__name__) +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.database import get_db +from backend.app.core.config import settings +from backend.app.models.printer import Printer +from backend.app.schemas.printer import ( + PrinterCreate, + PrinterUpdate, + PrinterResponse, + PrinterStatus, +) +from backend.app.services.printer_manager import printer_manager +from backend.app.services.bambu_ftp import ( + download_file_try_paths_async, + list_files_async, + delete_file_async, + download_file_bytes_async, + get_storage_info_async, +) + + +router = APIRouter(prefix="/printers", tags=["printers"]) + + +@router.get("/", response_model=list[PrinterResponse]) +async def list_printers(db: AsyncSession = Depends(get_db)): + """List all configured printers.""" + result = await db.execute(select(Printer).order_by(Printer.name)) + return list(result.scalars().all()) + + +@router.post("/", response_model=PrinterResponse) +async def create_printer( + printer_data: PrinterCreate, + db: AsyncSession = Depends(get_db), +): + """Add a new printer.""" + # Check if serial number already exists + result = await db.execute( + select(Printer).where(Printer.serial_number == printer_data.serial_number) + ) + if result.scalar_one_or_none(): + raise HTTPException(400, "Printer with this serial number already exists") + + printer = Printer(**printer_data.model_dump()) + db.add(printer) + await db.commit() + await db.refresh(printer) + + # Connect to the printer + if printer.is_active: + await printer_manager.connect_printer(printer) + + return printer + + +@router.get("/{printer_id}", response_model=PrinterResponse) +async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get a specific printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + return printer + + +@router.patch("/{printer_id}", response_model=PrinterResponse) +async def update_printer( + printer_id: int, + printer_data: PrinterUpdate, + db: AsyncSession = Depends(get_db), +): + """Update a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + update_data = printer_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(printer, field, value) + + await db.commit() + await db.refresh(printer) + + # Reconnect if connection settings changed + if any(k in update_data for k in ["ip_address", "access_code", "is_active"]): + printer_manager.disconnect_printer(printer_id) + if printer.is_active: + await printer_manager.connect_printer(printer) + + return printer + + +@router.delete("/{printer_id}") +async def delete_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Delete a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + printer_manager.disconnect_printer(printer_id) + await db.delete(printer) + await db.commit() + + return {"status": "deleted"} + + +@router.get("/{printer_id}/status", response_model=PrinterStatus) +async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get real-time status of a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + state = printer_manager.get_status(printer_id) + if not state: + return PrinterStatus( + id=printer_id, + name=printer.name, + connected=False, + ) + + # Determine cover URL if there's an active print + cover_url = None + if state.state == "RUNNING" and state.gcode_file: + cover_url = f"/api/v1/printers/{printer_id}/cover" + + return PrinterStatus( + id=printer_id, + name=printer.name, + connected=state.connected, + state=state.state, + current_print=state.current_print, + subtask_name=state.subtask_name, + gcode_file=state.gcode_file, + progress=state.progress, + remaining_time=state.remaining_time, + layer_num=state.layer_num, + total_layers=state.total_layers, + temperatures=state.temperatures, + cover_url=cover_url, + ) + + +@router.post("/{printer_id}/connect") +async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Manually connect to a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + success = await printer_manager.connect_printer(printer) + return {"connected": success} + + +@router.post("/{printer_id}/disconnect") +async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Manually disconnect from a printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + printer_manager.disconnect_printer(printer_id) + return {"connected": False} + + +@router.post("/test") +async def test_printer_connection( + ip_address: str, + serial_number: str, + access_code: str, +): + """Test connection to a printer without saving.""" + result = await printer_manager.test_connection( + ip_address=ip_address, + serial_number=serial_number, + access_code=access_code, + ) + return result + + +# Cache for cover images (printer_id -> (gcode_file, image_bytes)) +_cover_cache: dict[int, tuple[str, bytes]] = {} + + +@router.get("/{printer_id}/cover") +async def get_printer_cover(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get the cover image for the current print job.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + state = printer_manager.get_status(printer_id) + if not state: + raise HTTPException(404, "Printer not connected") + + # Use subtask_name as the 3MF filename (gcode_file is the path inside the 3MF) + subtask_name = state.subtask_name + if not subtask_name: + raise HTTPException(404, f"No subtask_name in printer state (state={state.state})") + + # Check cache + if printer_id in _cover_cache: + cached_file, cached_image = _cover_cache[printer_id] + if cached_file == subtask_name: + return Response(content=cached_image, media_type="image/png") + + # Build 3MF filename from subtask_name + # Bambu printers store files as "name.gcode.3mf" + filename = subtask_name + if not filename.endswith(".3mf"): + filename = filename + ".gcode.3mf" + + # Try to download the 3MF file from printer + temp_path = settings.archive_dir / "temp" / f"cover_{printer_id}_{filename}" + temp_path.parent.mkdir(parents=True, exist_ok=True) + + remote_paths = [ + f"/{filename}", # Root directory (most common) + f"/cache/{filename}", + f"/model/{filename}", + f"/data/{filename}", + ] + + logger.info(f"Trying to download cover for '{filename}' from {printer.ip_address}") + + try: + downloaded = await download_file_try_paths_async( + printer.ip_address, + printer.access_code, + remote_paths, + temp_path, + ) + except Exception as e: + logger.error(f"FTP download exception: {e}") + raise HTTPException(500, f"FTP download failed: {e}") + + if not downloaded: + raise HTTPException(404, f"Could not download 3MF file '{filename}' from printer {printer.ip_address}. Tried: {remote_paths}") + + # Verify file actually exists and has content + if not temp_path.exists(): + raise HTTPException(500, f"Download reported success but file not found: {temp_path}") + + file_size = temp_path.stat().st_size + logger.info(f"Downloaded file size: {file_size} bytes") + + if file_size == 0: + temp_path.unlink() + raise HTTPException(500, f"Downloaded file is empty: {filename}") + + try: + # Extract thumbnail from 3MF (which is a ZIP file) + try: + zf = zipfile.ZipFile(temp_path, 'r') + except zipfile.BadZipFile as e: + raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}") + except Exception as e: + raise HTTPException(500, f"Failed to open 3MF file: {e}") + + try: + # Try common thumbnail paths in 3MF files + thumbnail_paths = [ + "Metadata/plate_1.png", + "Metadata/thumbnail.png", + "Metadata/plate_1_small.png", + "Thumbnails/thumbnail.png", + "thumbnail.png", + ] + + for thumb_path in thumbnail_paths: + try: + image_data = zf.read(thumb_path) + # Cache the result + _cover_cache[printer_id] = (subtask_name, image_data) + return Response(content=image_data, media_type="image/png") + except KeyError: + continue + + # If no specific thumbnail found, try any PNG in Metadata + for name in zf.namelist(): + if name.startswith("Metadata/") and name.endswith(".png"): + image_data = zf.read(name) + _cover_cache[printer_id] = (subtask_name, image_data) + return Response(content=image_data, media_type="image/png") + + raise HTTPException(404, "No thumbnail found in 3MF file") + finally: + zf.close() + + finally: + if temp_path.exists(): + temp_path.unlink() + + +# ============================================ +# File Manager Endpoints +# ============================================ + +@router.get("/{printer_id}/files") +async def list_printer_files( + printer_id: int, + path: str = "/", + db: AsyncSession = Depends(get_db), +): + """List files on the printer at the specified path.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + files = await list_files_async(printer.ip_address, printer.access_code, path) + + # Add full path to each file + for f in files: + f["path"] = f"{path.rstrip('/')}/{f['name']}" if path != "/" else f"/{f['name']}" + + return { + "path": path, + "files": files, + } + + +@router.get("/{printer_id}/files/download") +async def download_printer_file( + printer_id: int, + path: str, + db: AsyncSession = Depends(get_db), +): + """Download a file from the printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + data = await download_file_bytes_async(printer.ip_address, printer.access_code, path) + if data is None: + raise HTTPException(404, f"File not found: {path}") + + # Determine content type based on extension + filename = path.split("/")[-1] + ext = filename.lower().split(".")[-1] if "." in filename else "" + + content_types = { + "3mf": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml", + "gcode": "text/plain", + "mp4": "video/mp4", + "avi": "video/x-msvideo", + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "json": "application/json", + "txt": "text/plain", + } + content_type = content_types.get(ext, "application/octet-stream") + + return Response( + content=data, + media_type=content_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.delete("/{printer_id}/files") +async def delete_printer_file( + printer_id: int, + path: str, + db: AsyncSession = Depends(get_db), +): + """Delete a file from the printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + success = await delete_file_async(printer.ip_address, printer.access_code, path) + if not success: + raise HTTPException(500, f"Failed to delete file: {path}") + + return {"status": "deleted", "path": path} + + +@router.get("/{printer_id}/storage") +async def get_printer_storage( + printer_id: int, + db: AsyncSession = Depends(get_db), +): + """Get storage information from the printer.""" + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer = result.scalar_one_or_none() + if not printer: + raise HTTPException(404, "Printer not found") + + storage_info = await get_storage_info_async(printer.ip_address, printer.access_code) + + return storage_info or {"used_bytes": None, "free_bytes": None} diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py new file mode 100644 index 000000000..1d47ae5c9 --- /dev/null +++ b/backend/app/api/routes/settings.py @@ -0,0 +1,90 @@ +import json +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.database import get_db +from backend.app.models.settings import Settings +from backend.app.schemas.settings import AppSettings, AppSettingsUpdate + + +router = APIRouter(prefix="/settings", tags=["settings"]) + +# Default settings +DEFAULT_SETTINGS = AppSettings() + + +async def get_setting(db: AsyncSession, key: str) -> str | None: + """Get a single setting value by key.""" + result = await db.execute(select(Settings).where(Settings.key == key)) + setting = result.scalar_one_or_none() + return setting.value if setting else None + + +async def set_setting(db: AsyncSession, key: str, value: str) -> None: + """Set a single setting value.""" + result = await db.execute(select(Settings).where(Settings.key == key)) + setting = result.scalar_one_or_none() + + if setting: + setting.value = value + else: + setting = Settings(key=key, value=value) + db.add(setting) + + +@router.get("/", response_model=AppSettings) +async def get_settings(db: AsyncSession = Depends(get_db)): + """Get all application settings.""" + settings_dict = DEFAULT_SETTINGS.model_dump() + + # Load saved settings from database + result = await db.execute(select(Settings)) + db_settings = result.scalars().all() + + for setting in db_settings: + if setting.key in settings_dict: + # Parse the value based on the expected type + if setting.key in ["auto_archive", "save_thumbnails"]: + settings_dict[setting.key] = setting.value.lower() == "true" + elif setting.key == "default_filament_cost": + settings_dict[setting.key] = float(setting.value) + else: + settings_dict[setting.key] = setting.value + + return AppSettings(**settings_dict) + + +@router.put("/", response_model=AppSettings) +async def update_settings( + settings_update: AppSettingsUpdate, + db: AsyncSession = Depends(get_db), +): + """Update application settings.""" + update_data = settings_update.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + # Convert value to string for storage + if isinstance(value, bool): + str_value = "true" if value else "false" + else: + str_value = str(value) + await set_setting(db, key, str_value) + + await db.commit() + + # Return updated settings + return await get_settings(db) + + +@router.post("/reset", response_model=AppSettings) +async def reset_settings(db: AsyncSession = Depends(get_db)): + """Reset all settings to defaults.""" + # Delete all settings + result = await db.execute(select(Settings)) + for setting in result.scalars().all(): + await db.delete(setting) + + await db.commit() + + return DEFAULT_SETTINGS diff --git a/backend/app/api/routes/websocket.py b/backend/app/api/routes/websocket.py new file mode 100644 index 000000000..f67e744b4 --- /dev/null +++ b/backend/app/api/routes/websocket.py @@ -0,0 +1,48 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from backend.app.core.websocket import ws_manager +from backend.app.services.printer_manager import printer_manager, printer_state_to_dict + + +router = APIRouter() + + +@router.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time updates.""" + await ws_manager.connect(websocket) + + try: + # Send initial status of all printers + statuses = printer_manager.get_all_statuses() + for printer_id, state in statuses.items(): + await websocket.send_json({ + "type": "printer_status", + "printer_id": printer_id, + "data": printer_state_to_dict(state), + }) + + # Keep connection alive and handle incoming messages + while True: + data = await websocket.receive_json() + + # Handle ping/pong for keepalive + if data.get("type") == "ping": + await websocket.send_json({"type": "pong"}) + + # Handle status request + elif data.get("type") == "get_status": + printer_id = data.get("printer_id") + if printer_id: + state = printer_manager.get_status(printer_id) + if state: + await websocket.send_json({ + "type": "printer_status", + "printer_id": printer_id, + "data": printer_state_to_dict(state), + }) + + except WebSocketDisconnect: + await ws_manager.disconnect(websocket) + except Exception: + await ws_manager.disconnect(websocket) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 000000000..aa8fa3ad2 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,27 @@ +from pathlib import Path +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + app_name: str = "BambuTrack" + debug: bool = True + + # Paths + base_dir: Path = Path(__file__).resolve().parent.parent.parent.parent + archive_dir: Path = base_dir / "archive" + static_dir: Path = base_dir / "static" + database_url: str = f"sqlite+aiosqlite:///{base_dir}/bambutrack.db" + + # API + api_prefix: str = "/api/v1" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() + +# Ensure directories exist +settings.archive_dir.mkdir(exist_ok=True) +settings.static_dir.mkdir(exist_ok=True) diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 000000000..ef056b9a6 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,57 @@ +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase + +from backend.app.core.config import settings + + +engine = create_async_engine( + settings.database_url, + echo=settings.debug, +) + +async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db() -> AsyncSession: + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db(): + # Import models to register them with SQLAlchemy + from backend.app.models import printer, archive, filament, settings # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Run migrations for new columns (SQLite doesn't auto-add columns) + await run_migrations(conn) + + +async def run_migrations(conn): + """Add new columns to existing tables if they don't exist.""" + from sqlalchemy import text + + # Migration: Add is_favorite column to print_archives + try: + await conn.execute(text( + "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0" + )) + except Exception: + # Column already exists + pass diff --git a/backend/app/core/websocket.py b/backend/app/core/websocket.py new file mode 100644 index 000000000..30d21f996 --- /dev/null +++ b/backend/app/core/websocket.py @@ -0,0 +1,85 @@ +import asyncio +import json +from typing import Any +from fastapi import WebSocket + + +class ConnectionManager: + """Manages WebSocket connections and broadcasts.""" + + def __init__(self): + self.active_connections: list[WebSocket] = [] + self._lock = asyncio.Lock() + + async def connect(self, websocket: WebSocket): + """Accept a new WebSocket connection.""" + await websocket.accept() + async with self._lock: + self.active_connections.append(websocket) + + async def disconnect(self, websocket: WebSocket): + """Remove a WebSocket connection.""" + async with self._lock: + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast(self, message: dict[str, Any]): + """Broadcast a message to all connected clients.""" + if not self.active_connections: + return + + data = json.dumps(message) + async with self._lock: + disconnected = [] + for connection in self.active_connections: + try: + await connection.send_text(data) + except Exception: + disconnected.append(connection) + + # Clean up disconnected clients + for conn in disconnected: + if conn in self.active_connections: + self.active_connections.remove(conn) + + async def send_printer_status(self, printer_id: int, status: dict): + """Send printer status update to all clients.""" + await self.broadcast({ + "type": "printer_status", + "printer_id": printer_id, + "data": status, + }) + + async def send_print_start(self, printer_id: int, data: dict): + """Notify clients that a print has started.""" + await self.broadcast({ + "type": "print_start", + "printer_id": printer_id, + "data": data, + }) + + async def send_print_complete(self, printer_id: int, data: dict): + """Notify clients that a print has completed.""" + await self.broadcast({ + "type": "print_complete", + "printer_id": printer_id, + "data": data, + }) + + async def send_archive_created(self, archive: dict): + """Notify clients that a new archive was created.""" + await self.broadcast({ + "type": "archive_created", + "data": archive, + }) + + async def send_archive_updated(self, archive: dict): + """Notify clients that an archive was updated.""" + await self.broadcast({ + "type": "archive_updated", + "data": archive, + }) + + +# Global connection manager +ws_manager = ConnectionManager() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 000000000..1de8b6386 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,339 @@ +import asyncio +from datetime import datetime +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +from backend.app.core.config import settings as app_settings +from backend.app.core.database import init_db, async_session +from backend.app.core.websocket import ws_manager +from backend.app.api.routes import printers, archives, websocket, filaments, cloud +from backend.app.api.routes import settings as settings_routes +from backend.app.services.printer_manager import ( + printer_manager, + printer_state_to_dict, + init_printer_connections, +) +from backend.app.services.bambu_mqtt import PrinterState +from backend.app.services.archive import ArchiveService +from backend.app.services.bambu_ftp import download_file_async + + +# Track active prints: {(printer_id, filename): archive_id} +_active_prints: dict[tuple[int, str], int] = {} + + +async def on_printer_status_change(printer_id: int, state: PrinterState): + """Handle printer status changes - broadcast via WebSocket.""" + await ws_manager.send_printer_status( + printer_id, + printer_state_to_dict(state, printer_id), + ) + + +async def on_print_start(printer_id: int, data: dict): + """Handle print start - archive the 3MF file immediately.""" + import logging + logger = logging.getLogger(__name__) + + await ws_manager.send_print_start(printer_id, data) + + async with async_session() as db: + from backend.app.models.printer import Printer + from backend.app.services.bambu_ftp import list_files_async + from sqlalchemy import select + + result = await db.execute( + select(Printer).where(Printer.id == printer_id) + ) + printer = result.scalar_one_or_none() + + if not printer or not printer.auto_archive: + return + + # Get the filename and subtask_name + filename = data.get("filename", "") + subtask_name = data.get("subtask_name", "") + + logger.info(f"Print start detected - filename: {filename}, subtask: {subtask_name}") + + if not filename and not subtask_name: + return + + # Build list of possible 3MF filenames to try + possible_names = [] + + # Try original filename with .3mf extension + if filename: + if filename.endswith(".3mf"): + possible_names.append(filename) + elif filename.endswith(".gcode"): + base = filename.rsplit(".", 1)[0] + possible_names.append(f"{base}.3mf") + else: + # No extension - try adding .3mf + possible_names.append(f"{filename}.3mf") + possible_names.append(filename) + + # Try subtask_name with .3mf extension + if subtask_name and subtask_name != filename: + possible_names.append(f"{subtask_name}.3mf") + + # Remove duplicates while preserving order + seen = set() + possible_names = [x for x in possible_names if not (x in seen or seen.add(x))] + + logger.info(f"Trying filenames: {possible_names}") + + # Try to find and download the 3MF file + temp_path = None + downloaded_filename = None + + for try_filename in possible_names: + if not try_filename.endswith(".3mf"): + continue + + remote_paths = [ + f"/cache/{try_filename}", + f"/model/{try_filename}", + f"/{try_filename}", + ] + + temp_path = app_settings.archive_dir / "temp" / try_filename + temp_path.parent.mkdir(parents=True, exist_ok=True) + + for remote_path in remote_paths: + if await download_file_async( + printer.ip_address, + printer.access_code, + remote_path, + temp_path, + ): + downloaded_filename = try_filename + logger.info(f"Downloaded: {remote_path}") + break + + if downloaded_filename: + break + + # If still not found, try listing /cache to find matching file + if not downloaded_filename and (filename or subtask_name): + search_term = (subtask_name or filename).lower().replace(".gcode", "").replace(".3mf", "") + try: + cache_files = await list_files_async(printer.ip_address, printer.access_code, "/cache") + for f in cache_files: + if f.get("is_directory"): + continue + fname = f.get("name", "") + if fname.endswith(".3mf") and search_term in fname.lower(): + temp_path = app_settings.archive_dir / "temp" / fname + temp_path.parent.mkdir(parents=True, exist_ok=True) + if await download_file_async( + printer.ip_address, + printer.access_code, + f"/cache/{fname}", + temp_path, + ): + downloaded_filename = fname + logger.info(f"Found and downloaded from cache: {fname}") + break + except Exception as e: + logger.warning(f"Failed to list cache: {e}") + + if not downloaded_filename or not temp_path: + logger.warning(f"Could not find 3MF file for print: {filename or subtask_name}") + return + + try: + # Archive the file with status "printing" + service = ArchiveService(db) + archive = await service.archive_print( + printer_id=printer_id, + source_file=temp_path, + print_data={**data, "status": "printing"}, + ) + + if archive: + # Track this active print (use both original filename and downloaded filename) + _active_prints[(printer_id, downloaded_filename)] = archive.id + if filename and filename != downloaded_filename: + _active_prints[(printer_id, filename)] = archive.id + if subtask_name: + _active_prints[(printer_id, f"{subtask_name}.3mf")] = archive.id + + logger.info(f"Created archive {archive.id} for {downloaded_filename}") + + await ws_manager.send_archive_created({ + "id": archive.id, + "printer_id": archive.printer_id, + "filename": archive.filename, + "print_name": archive.print_name, + "status": archive.status, + }) + finally: + if temp_path and temp_path.exists(): + temp_path.unlink() + + +async def on_print_complete(printer_id: int, data: dict): + """Handle print completion - update the archive status.""" + import logging + logger = logging.getLogger(__name__) + + await ws_manager.send_print_complete(printer_id, data) + + filename = data.get("filename", "") + if not filename: + return + + logger.info(f"Print complete - filename: {filename}, status: {data.get('status')}") + + # Build list of possible keys to try + possible_keys = [] + + if filename.endswith(".3mf"): + possible_keys.append((printer_id, filename)) + elif filename.endswith(".gcode"): + base_name = filename.rsplit(".", 1)[0] + possible_keys.append((printer_id, f"{base_name}.3mf")) + possible_keys.append((printer_id, filename)) + else: + possible_keys.append((printer_id, f"{filename}.3mf")) + possible_keys.append((printer_id, filename)) + + # Find the archive for this print + archive_id = None + for key in possible_keys: + archive_id = _active_prints.pop(key, None) + if archive_id: + # Also clean up any other keys pointing to this archive + keys_to_remove = [k for k, v in _active_prints.items() if v == archive_id] + for k in keys_to_remove: + _active_prints.pop(k, None) + break + + if not archive_id: + # Try to find by filename if not tracked (for prints started before app) + async with async_session() as db: + from backend.app.models.archive import PrintArchive + from sqlalchemy import select + + result = await db.execute( + select(PrintArchive) + .where(PrintArchive.printer_id == printer_id) + .where(PrintArchive.filename == filename) + .where(PrintArchive.status == "printing") + .order_by(PrintArchive.created_at.desc()) + .limit(1) + ) + archive = result.scalar_one_or_none() + if archive: + archive_id = archive.id + + if not archive_id: + return + + # Update archive status + async with async_session() as db: + service = ArchiveService(db) + status = data.get("status", "completed") + await service.update_archive_status( + archive_id, + status=status, + completed_at=datetime.now() if status in ("completed", "failed") else None, + ) + + await ws_manager.send_archive_updated({ + "id": archive_id, + "status": status, + }) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + await init_db() + + # Set up printer manager callbacks + loop = asyncio.get_event_loop() + printer_manager.set_event_loop(loop) + printer_manager.set_status_change_callback(on_printer_status_change) + printer_manager.set_print_start_callback(on_print_start) + printer_manager.set_print_complete_callback(on_print_complete) + + # Connect to all active printers + async with async_session() as db: + await init_printer_connections(db) + + yield + + # Shutdown + printer_manager.disconnect_all() + + +app = FastAPI( + title=app_settings.app_name, + description="Archive and manage Bambu Lab 3MF files", + version="0.1.1", + lifespan=lifespan, +) + +# API routes +app.include_router(printers.router, prefix=app_settings.api_prefix) +app.include_router(archives.router, prefix=app_settings.api_prefix) +app.include_router(filaments.router, prefix=app_settings.api_prefix) +app.include_router(settings_routes.router, prefix=app_settings.api_prefix) +app.include_router(cloud.router, prefix=app_settings.api_prefix) +app.include_router(websocket.router, prefix=app_settings.api_prefix) + + +# Serve static files (React build) +if app_settings.static_dir.exists() and any(app_settings.static_dir.iterdir()): + app.mount( + "/assets", + StaticFiles(directory=app_settings.static_dir / "assets"), + name="assets", + ) + if (app_settings.static_dir / "img").exists(): + app.mount( + "/img", + StaticFiles(directory=app_settings.static_dir / "img"), + name="img", + ) + + +@app.get("/") +async def serve_frontend(): + """Serve the React frontend.""" + index_file = app_settings.static_dir / "index.html" + if index_file.exists(): + return FileResponse(index_file) + return { + "message": "BambuTrack API", + "docs": "/docs", + "frontend": "Build and place React app in /static directory", + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + +# Catch-all route for React Router (must be last) +@app.get("/{full_path:path}") +async def serve_spa(full_path: str): + """Serve React app for client-side routing.""" + # Don't intercept API routes + if full_path.startswith("api/"): + return {"error": "Not found"} + + index_file = app_settings.static_dir / "index.html" + if index_file.exists(): + return FileResponse(index_file) + + return {"error": "Frontend not built"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py new file mode 100644 index 000000000..7c0719386 --- /dev/null +++ b/backend/app/models/archive.py @@ -0,0 +1,61 @@ +from datetime import datetime +from sqlalchemy import String, Integer, Float, DateTime, ForeignKey, Text, JSON, Boolean, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class PrintArchive(Base): + __tablename__ = "print_archives" + + id: Mapped[int] = mapped_column(primary_key=True) + printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True) + + # File info + filename: Mapped[str] = mapped_column(String(255)) + file_path: Mapped[str] = mapped_column(String(500)) + file_size: Mapped[int] = mapped_column(Integer) + thumbnail_path: Mapped[str | None] = mapped_column(String(500)) + timelapse_path: Mapped[str | None] = mapped_column(String(500)) + + # Print details from 3MF / printer + print_name: Mapped[str | None] = mapped_column(String(255)) + print_time_seconds: Mapped[int | None] = mapped_column(Integer) + filament_used_grams: Mapped[float | None] = mapped_column(Float) + filament_type: Mapped[str | None] = mapped_column(String(50)) + filament_color: Mapped[str | None] = mapped_column(String(50)) + layer_height: Mapped[float | None] = mapped_column(Float) + nozzle_diameter: Mapped[float | None] = mapped_column(Float) + bed_temperature: Mapped[int | None] = mapped_column(Integer) + nozzle_temperature: Mapped[int | None] = mapped_column(Integer) + + # Print result + status: Mapped[str] = mapped_column(String(20), default="completed") + started_at: Mapped[datetime | None] = mapped_column(DateTime) + completed_at: Mapped[datetime | None] = mapped_column(DateTime) + + # Extended metadata (JSON blob for flexibility) + extra_data: Mapped[dict | None] = mapped_column(JSON) + + # MakerWorld info + makerworld_url: Mapped[str | None] = mapped_column(String(500)) + designer: Mapped[str | None] = mapped_column(String(255)) + + # User additions + is_favorite: Mapped[bool] = mapped_column(Boolean, default=False) + tags: Mapped[str | None] = mapped_column(Text) + notes: Mapped[str | None] = mapped_column(Text) + cost: Mapped[float | None] = mapped_column(Float) + photos: Mapped[list | None] = mapped_column(JSON) # List of photo filenames + failure_reason: Mapped[str | None] = mapped_column(String(100)) # For failed prints + + # Timestamps + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) + + # Relationships + printer: Mapped["Printer | None"] = relationship(back_populates="archives") + + +from backend.app.models.printer import Printer # noqa: E402, F811 diff --git a/backend/app/models/filament.py b/backend/app/models/filament.py new file mode 100644 index 000000000..a39f56fa5 --- /dev/null +++ b/backend/app/models/filament.py @@ -0,0 +1,37 @@ +from datetime import datetime +from sqlalchemy import String, Float, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class Filament(Base): + """Filament type with cost information.""" + + __tablename__ = "filaments" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + type: Mapped[str] = mapped_column(String(50)) # PLA, PETG, ABS, etc. + brand: Mapped[str | None] = mapped_column(String(100)) + color: Mapped[str | None] = mapped_column(String(50)) + color_hex: Mapped[str | None] = mapped_column(String(7)) # #RRGGBB + + # Cost information + cost_per_kg: Mapped[float] = mapped_column(Float, default=25.0) + spool_weight_g: Mapped[float] = mapped_column(Float, default=1000.0) + currency: Mapped[str] = mapped_column(String(3), default="USD") + + # Properties + density: Mapped[float | None] = mapped_column(Float) # g/cm³ + print_temp_min: Mapped[int | None] = mapped_column() + print_temp_max: Mapped[int | None] = mapped_column() + bed_temp_min: Mapped[int | None] = mapped_column() + bed_temp_max: Mapped[int | None] = mapped_column() + + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py new file mode 100644 index 000000000..f7b08cd0b --- /dev/null +++ b/backend/app/models/printer.py @@ -0,0 +1,32 @@ +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class Printer(Base): + __tablename__ = "printers" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + serial_number: Mapped[str] = mapped_column(String(50), unique=True) + ip_address: Mapped[str] = mapped_column(String(45)) + access_code: Mapped[str] = mapped_column(String(20)) + model: Mapped[str | None] = mapped_column(String(50)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + auto_archive: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) + + # Relationships + archives: Mapped[list["PrintArchive"]] = relationship( + back_populates="printer", cascade="all, delete-orphan" + ) + + +from backend.app.models.archive import PrintArchive # noqa: E402 diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 000000000..9021862b4 --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,21 @@ +from datetime import datetime +from sqlalchemy import String, Text, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class Settings(Base): + """App settings stored as key-value pairs.""" + + __tablename__ = "settings" + + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, index=True) + value: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py new file mode 100644 index 000000000..e78608d82 --- /dev/null +++ b/backend/app/schemas/archive.py @@ -0,0 +1,67 @@ +from datetime import datetime +from pydantic import BaseModel + + +class ArchiveBase(BaseModel): + print_name: str | None = None + is_favorite: bool | None = None + tags: str | None = None + notes: str | None = None + cost: float | None = None + failure_reason: str | None = None + + +class ArchiveUpdate(ArchiveBase): + printer_id: int | None = None + + +class ArchiveResponse(BaseModel): + id: int + printer_id: int | None + filename: str + file_path: str + file_size: int + thumbnail_path: str | None + timelapse_path: str | None + + print_name: str | None + print_time_seconds: int | None + filament_used_grams: float | None + filament_type: str | None + filament_color: str | None + layer_height: float | None + nozzle_diameter: float | None + bed_temperature: int | None + nozzle_temperature: int | None + + status: str + started_at: datetime | None + completed_at: datetime | None + + extra_data: dict | None + + makerworld_url: str | None + designer: str | None + + is_favorite: bool + tags: str | None + notes: str | None + cost: float | None + photos: list | None + failure_reason: str | None + + created_at: datetime + + class Config: + from_attributes = True + + +class ArchiveStats(BaseModel): + total_prints: int + successful_prints: int + failed_prints: int + total_print_time_hours: float + total_filament_grams: float + total_cost: float + prints_by_filament_type: dict + prints_by_printer: dict diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py new file mode 100644 index 000000000..ac1868558 --- /dev/null +++ b/backend/app/schemas/cloud.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class CloudLoginRequest(BaseModel): + """Request to initiate cloud login.""" + email: str = Field(..., description="Bambu Lab account email") + password: str = Field(..., description="Account password") + region: str = Field(default="global", description="Region: 'global' or 'china'") + + +class CloudVerifyRequest(BaseModel): + """Request to verify login with 2FA code.""" + email: str = Field(..., description="Bambu Lab account email") + code: str = Field(..., description="6-digit verification code from email") + + +class CloudLoginResponse(BaseModel): + """Response from login attempt.""" + success: bool + needs_verification: bool = False + message: str + + +class CloudAuthStatus(BaseModel): + """Current authentication status.""" + is_authenticated: bool + email: Optional[str] = None + + +class CloudTokenRequest(BaseModel): + """Request to set access token directly.""" + access_token: str = Field(..., description="Bambu Lab access token") + + +class SlicerSetting(BaseModel): + """A slicer setting/preset.""" + setting_id: str + name: str + type: str # filament, printer, process + version: Optional[str] = None + user_id: Optional[str] = None + updated_time: Optional[str] = None + + +class SlicerSettingsResponse(BaseModel): + """Response containing slicer settings.""" + filament: list[SlicerSetting] = [] + printer: list[SlicerSetting] = [] + process: list[SlicerSetting] = [] + + +class CloudDevice(BaseModel): + """A bound printer device.""" + dev_id: str + name: str + dev_model_name: Optional[str] = None + dev_product_name: Optional[str] = None + online: bool = False diff --git a/backend/app/schemas/filament.py b/backend/app/schemas/filament.py new file mode 100644 index 000000000..ef9f5827b --- /dev/null +++ b/backend/app/schemas/filament.py @@ -0,0 +1,55 @@ +from datetime import datetime +from pydantic import BaseModel, Field + + +class FilamentBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + type: str = Field(..., min_length=1, max_length=50) + brand: str | None = None + color: str | None = None + color_hex: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$") + cost_per_kg: float = 25.0 + spool_weight_g: float = 1000.0 + currency: str = "USD" + density: float | None = None + print_temp_min: int | None = None + print_temp_max: int | None = None + bed_temp_min: int | None = None + bed_temp_max: int | None = None + + +class FilamentCreate(FilamentBase): + pass + + +class FilamentUpdate(BaseModel): + name: str | None = None + type: str | None = None + brand: str | None = None + color: str | None = None + color_hex: str | None = None + cost_per_kg: float | None = None + spool_weight_g: float | None = None + currency: str | None = None + density: float | None = None + print_temp_min: int | None = None + print_temp_max: int | None = None + bed_temp_min: int | None = None + bed_temp_max: int | None = None + + +class FilamentResponse(FilamentBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FilamentCostCalculation(BaseModel): + filament_id: int + filament_name: str + weight_grams: float + cost: float + currency: str diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py new file mode 100644 index 000000000..d572f13f2 --- /dev/null +++ b/backend/app/schemas/printer.py @@ -0,0 +1,50 @@ +from datetime import datetime +from pydantic import BaseModel, Field + + +class PrinterBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + serial_number: str = Field(..., min_length=1, max_length=50) + ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") + access_code: str = Field(..., min_length=1, max_length=20) + model: str | None = None + auto_archive: bool = True + + +class PrinterCreate(PrinterBase): + pass + + +class PrinterUpdate(BaseModel): + name: str | None = None + ip_address: str | None = None + access_code: str | None = None + model: str | None = None + is_active: bool | None = None + auto_archive: bool | None = None + + +class PrinterResponse(PrinterBase): + id: int + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class PrinterStatus(BaseModel): + id: int + name: str + connected: bool + state: str | None = None + current_print: str | None = None + subtask_name: str | None = None + gcode_file: str | None = None + progress: float | None = None + remaining_time: int | None = None + layer_num: int | None = None + total_layers: int | None = None + temperatures: dict | None = None + cover_url: str | None = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py new file mode 100644 index 000000000..9f9d7ad03 --- /dev/null +++ b/backend/app/schemas/settings.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field + + +class AppSettings(BaseModel): + """Application settings schema.""" + + auto_archive: bool = Field(default=True, description="Automatically archive prints when completed") + save_thumbnails: bool = Field(default=True, description="Extract and save preview images from 3MF files") + default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg") + currency: str = Field(default="USD", description="Currency for cost tracking") + + +class AppSettingsUpdate(BaseModel): + """Schema for updating settings (all fields optional).""" + + auto_archive: bool | None = None + save_thumbnails: bool | None = None + default_filament_cost: float | None = None + currency: str | None = None diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py new file mode 100644 index 000000000..782188f0a --- /dev/null +++ b/backend/app/services/archive.py @@ -0,0 +1,434 @@ +import json +import zipfile +import shutil +from datetime import datetime +from pathlib import Path +from xml.etree import ElementTree as ET + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.config import settings +from backend.app.models.archive import PrintArchive +from backend.app.models.printer import Printer + + +class ThreeMFParser: + """Parser for Bambu Lab 3MF files.""" + + def __init__(self, file_path: Path): + self.file_path = file_path + self.metadata: dict = {} + + def parse(self) -> dict: + """Extract metadata from 3MF file.""" + try: + with zipfile.ZipFile(self.file_path, "r") as zf: + self._parse_slice_info(zf) + self._parse_project_settings(zf) + self._parse_3dmodel(zf) + self._extract_thumbnail(zf) + + # Prefer slice_info for colors (shows ALL filaments actually used in print) + # project_settings may filter out "support" filaments incorrectly + if self.metadata.get("_slice_filament_color"): + self.metadata["filament_color"] = self.metadata["_slice_filament_color"] + if not self.metadata.get("filament_type") and self.metadata.get("_slice_filament_type"): + self.metadata["filament_type"] = self.metadata["_slice_filament_type"] + + # Clean up internal keys + self.metadata.pop("_slice_filament_type", None) + self.metadata.pop("_slice_filament_color", None) + except Exception: + pass + return self.metadata + + def _parse_slice_info(self, zf: zipfile.ZipFile): + """Parse slice_info.config for print settings.""" + try: + if "Metadata/slice_info.config" in zf.namelist(): + content = zf.read("Metadata/slice_info.config").decode() + root = ET.fromstring(content) + + # Get first plate's metadata + plate = root.find(".//plate") + if plate is not None: + # Get prediction and weight from metadata elements + for meta in plate.findall("metadata"): + key = meta.get("key") + value = meta.get("value") + if key == "prediction" and value: + self.metadata["print_time_seconds"] = int(value) + elif key == "weight" and value: + self.metadata["filament_used_grams"] = float(value) + + # Get filament info from ALL filaments actually used in the print + # slice_info has + filaments = root.findall(".//filament") + if filaments: + # Collect all unique filament types and colors + types = [] + colors = [] + for f in filaments: + ftype = f.get("type") + fcolor = f.get("color") + if ftype and ftype not in types: + types.append(ftype) + if fcolor and fcolor not in colors: + colors.append(fcolor) + + if types: + self.metadata["_slice_filament_type"] = ", ".join(types) + if colors: + self.metadata["_slice_filament_color"] = ",".join(colors) + except Exception: + pass + + def _parse_project_settings(self, zf: zipfile.ZipFile): + """Parse project settings for print configuration.""" + try: + if "Metadata/project_settings.config" in zf.namelist(): + content = zf.read("Metadata/project_settings.config").decode() + try: + data = json.loads(content) + self._extract_filament_info(data) + self._extract_print_settings(data) + except json.JSONDecodeError: + pass + except Exception: + pass + + def _extract_filament_info(self, data: dict): + """Extract filament info, preferring non-support filaments.""" + try: + filament_types = data.get("filament_type", []) + filament_colors = data.get("filament_colour", []) + filament_is_support = data.get("filament_is_support", []) + + if not filament_types: + return + + # Collect all non-support filaments + non_support_types = [] + non_support_colors = [] + + for i, ftype in enumerate(filament_types): + is_support = filament_is_support[i] if i < len(filament_is_support) else '0' + if is_support == '0': + if ftype and ftype not in non_support_types: + non_support_types.append(ftype) + if i < len(filament_colors) and filament_colors[i]: + color = filament_colors[i] + if color not in non_support_colors: + non_support_colors.append(color) + + # Fallback to first filament if all are support + if not non_support_types and filament_types: + non_support_types = [filament_types[0]] + if not non_support_colors and filament_colors: + non_support_colors = [filament_colors[0]] + + # Store filament type(s) + if non_support_types: + self.metadata["filament_type"] = ", ".join(non_support_types) + + # Store all colors as comma-separated (for multi-color display) + if non_support_colors: + self.metadata["filament_color"] = ",".join(non_support_colors) + + except Exception: + pass + + def _extract_print_settings(self, data: dict): + """Extract print settings from JSON config.""" + try: + # Layer height - usually an array, get first value + if "layer_height" in data: + val = data["layer_height"] + if isinstance(val, list) and val: + self.metadata["layer_height"] = float(val[0]) + elif isinstance(val, (int, float, str)): + self.metadata["layer_height"] = float(val) + + # Nozzle diameter + if "nozzle_diameter" in data: + val = data["nozzle_diameter"] + if isinstance(val, list) and val: + self.metadata["nozzle_diameter"] = float(val[0]) + elif isinstance(val, (int, float, str)): + self.metadata["nozzle_diameter"] = float(val) + + # Bed temperature - first layer or regular + for key in ["bed_temperature_initial_layer", "bed_temperature"]: + if key in data: + val = data[key] + if isinstance(val, list) and val: + self.metadata["bed_temperature"] = int(float(val[0])) + elif isinstance(val, (int, float, str)): + self.metadata["bed_temperature"] = int(float(val)) + break + + # Nozzle temperature + for key in ["nozzle_temperature_initial_layer", "nozzle_temperature"]: + if key in data: + val = data[key] + if isinstance(val, list) and val: + self.metadata["nozzle_temperature"] = int(float(val[0])) + elif isinstance(val, (int, float, str)): + self.metadata["nozzle_temperature"] = int(float(val)) + break + except Exception: + pass + + def _extract_settings_from_content(self, content: str): + """Extract print settings from config content.""" + settings_map = { + "layer_height": ("layer_height", float), + "nozzle_diameter": ("nozzle_diameter", float), + "bed_temperature": ("bed_temperature", int), + "nozzle_temperature": ("nozzle_temperature", int), + } + + for key, (search_key, converter) in settings_map.items(): + if key not in self.metadata: + try: + # Try JSON format + if f'"{search_key}"' in content: + start = content.find(f'"{search_key}"') + value_start = content.find(":", start) + 1 + value_end = content.find(",", value_start) + if value_end == -1: + value_end = content.find("}", value_start) + value = content[value_start:value_end].strip().strip('"') + self.metadata[key] = converter(value) + except Exception: + pass + + def _parse_3dmodel(self, zf: zipfile.ZipFile): + """Parse 3D/3dmodel.model for MakerWorld metadata.""" + import re + try: + model_path = "3D/3dmodel.model" + if model_path not in zf.namelist(): + return + + content = zf.read(model_path).decode("utf-8", errors="ignore") + + # Parse XML metadata elements + # MakerWorld adds metadata like: username + metadata_pattern = r']*>([^<]*)' + matches = re.findall(metadata_pattern, content) + + makerworld_fields = {} + for name, value in matches: + makerworld_fields[name] = value.strip() + + # Check for direct MakerWorld URL in content + url_pattern = r'https?://makerworld\.com/[^\s<>"\']+/models/(\d+)' + url_match = re.search(url_pattern, content) + if url_match: + self.metadata["makerworld_url"] = url_match.group(0) + self.metadata["makerworld_model_id"] = url_match.group(1) + + # Extract model ID from DSM reference in image URLs + # Format: https://makerworld.bblmw.com/makerworld/model/DSM00000001275614/... + # The numeric part (1275614) is the MakerWorld model ID + if "makerworld_url" not in self.metadata: + dsm_pattern = r'DSM0+(\d+)' + dsm_match = re.search(dsm_pattern, content) + if dsm_match: + model_id = dsm_match.group(1) + self.metadata["makerworld_url"] = f"https://makerworld.com/en/models/{model_id}" + self.metadata["makerworld_model_id"] = model_id + + # Store designer info + if "Designer" in makerworld_fields: + self.metadata["designer"] = makerworld_fields["Designer"] + if "Title" in makerworld_fields: + self.metadata["print_name"] = makerworld_fields["Title"] + + except Exception: + pass + + def _extract_thumbnail(self, zf: zipfile.ZipFile): + """Extract thumbnail image from 3MF.""" + thumbnail_paths = [ + "Metadata/plate_1.png", + "Metadata/thumbnail.png", + "Metadata/model_thumbnail.png", + ] + for thumb_path in thumbnail_paths: + if thumb_path in zf.namelist(): + self.metadata["_thumbnail_data"] = zf.read(thumb_path) + self.metadata["_thumbnail_ext"] = ".png" + break + + +class ArchiveService: + """Service for archiving print jobs.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def archive_print( + self, + printer_id: int | None, + source_file: Path, + print_data: dict | None = None, + ) -> PrintArchive | None: + """Archive a 3MF file with metadata.""" + # Verify printer exists if specified + if printer_id is not None: + result = await self.db.execute( + select(Printer).where(Printer.id == printer_id) + ) + printer = result.scalar_one_or_none() + if not printer: + return None + + # Create archive directory structure + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + archive_name = f"{timestamp}_{source_file.stem}" + # Use "unassigned" folder for archives without a printer + printer_folder = str(printer_id) if printer_id is not None else "unassigned" + archive_dir = settings.archive_dir / printer_folder / archive_name + archive_dir.mkdir(parents=True, exist_ok=True) + + # Copy 3MF file + dest_file = archive_dir / source_file.name + shutil.copy2(source_file, dest_file) + + # Parse 3MF metadata + parser = ThreeMFParser(dest_file) + metadata = parser.parse() + + # Save thumbnail if present + thumbnail_path = None + if "_thumbnail_data" in metadata: + thumb_file = archive_dir / f"thumbnail{metadata['_thumbnail_ext']}" + thumb_file.write_bytes(metadata["_thumbnail_data"]) + thumbnail_path = str(thumb_file.relative_to(settings.base_dir)) + del metadata["_thumbnail_data"] + del metadata["_thumbnail_ext"] + + # Merge with print data from MQTT + if print_data: + metadata["_print_data"] = print_data + + # Determine status and timestamps + status = print_data.get("status", "completed") if print_data else "archived" + started_at = datetime.now() if status == "printing" else None + completed_at = datetime.now() if status in ("completed", "failed", "archived") else None + + # Create archive record + archive = PrintArchive( + printer_id=printer_id, + filename=source_file.name, + file_path=str(dest_file.relative_to(settings.base_dir)), + file_size=dest_file.stat().st_size, + thumbnail_path=thumbnail_path, + print_name=metadata.get("print_name") or source_file.stem, + print_time_seconds=metadata.get("print_time_seconds"), + filament_used_grams=metadata.get("filament_used_grams"), + filament_type=metadata.get("filament_type"), + filament_color=metadata.get("filament_color"), + layer_height=metadata.get("layer_height"), + nozzle_diameter=metadata.get("nozzle_diameter"), + bed_temperature=metadata.get("bed_temperature"), + nozzle_temperature=metadata.get("nozzle_temperature"), + makerworld_url=metadata.get("makerworld_url"), + designer=metadata.get("designer"), + status=status, + started_at=started_at, + completed_at=completed_at, + extra_data=metadata, + ) + + self.db.add(archive) + await self.db.commit() + await self.db.refresh(archive) + + return archive + + async def get_archive(self, archive_id: int) -> PrintArchive | None: + """Get an archive by ID.""" + result = await self.db.execute( + select(PrintArchive).where(PrintArchive.id == archive_id) + ) + return result.scalar_one_or_none() + + async def update_archive_status( + self, + archive_id: int, + status: str, + completed_at: datetime | None = None, + ) -> bool: + """Update the status of an archive.""" + archive = await self.get_archive(archive_id) + if not archive: + return False + + archive.status = status + if completed_at: + archive.completed_at = completed_at + + await self.db.commit() + return True + + async def list_archives( + self, + printer_id: int | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[PrintArchive]: + """List archives with optional filtering.""" + query = select(PrintArchive).order_by(PrintArchive.created_at.desc()) + + if printer_id: + query = query.where(PrintArchive.printer_id == printer_id) + + query = query.limit(limit).offset(offset) + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def delete_archive(self, archive_id: int) -> bool: + """Delete an archive and its files.""" + archive = await self.get_archive(archive_id) + if not archive: + return False + + # Delete files + file_path = settings.base_dir / archive.file_path + if file_path.exists(): + archive_dir = file_path.parent + shutil.rmtree(archive_dir, ignore_errors=True) + + # Delete database record + await self.db.delete(archive) + await self.db.commit() + return True + + async def attach_timelapse( + self, + archive_id: int, + timelapse_data: bytes, + filename: str = "timelapse.mp4", + ) -> bool: + """Attach a timelapse video to an archive.""" + archive = await self.get_archive(archive_id) + if not archive: + return False + + # Get archive directory + file_path = settings.base_dir / archive.file_path + archive_dir = file_path.parent + + # Save timelapse + timelapse_file = archive_dir / filename + timelapse_file.write_bytes(timelapse_data) + + # Update archive record + archive.timelapse_path = str(timelapse_file.relative_to(settings.base_dir)) + await self.db.commit() + + return True diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py new file mode 100644 index 000000000..393c11032 --- /dev/null +++ b/backend/app/services/bambu_cloud.py @@ -0,0 +1,256 @@ +""" +Bambu Lab Cloud API Service + +Handles authentication and profile management with Bambu Lab's cloud services. +""" + +import httpx +import json +import logging +from typing import Optional +from pathlib import Path +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +BAMBU_API_BASE = "https://api.bambulab.com" +BAMBU_API_BASE_CN = "https://api.bambulab.cn" + + +class BambuCloudError(Exception): + """Base exception for Bambu Cloud errors.""" + pass + + +class BambuCloudAuthError(BambuCloudError): + """Authentication related errors.""" + pass + + +class BambuCloudService: + """Service for interacting with Bambu Lab Cloud API.""" + + def __init__(self, region: str = "global"): + self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN + self.access_token: Optional[str] = None + self.refresh_token: Optional[str] = None + self.token_expiry: Optional[datetime] = None + self._client = httpx.AsyncClient(timeout=30.0) + + @property + def is_authenticated(self) -> bool: + """Check if we have a valid token.""" + if not self.access_token: + return False + if self.token_expiry and datetime.now() > self.token_expiry: + return False + return True + + def _get_headers(self) -> dict: + """Get headers for authenticated requests.""" + headers = { + "Content-Type": "application/json", + "User-Agent": "BambuTrack/1.0", + } + if self.access_token: + headers["Authorization"] = f"Bearer {self.access_token}" + return headers + + async def login_request(self, email: str, password: str) -> dict: + """ + Initiate login - this will trigger a verification code email. + + Returns dict with login status and whether verification is needed. + """ + try: + response = await self._client.post( + f"{self.base_url}/v1/user-service/user/login", + headers={"Content-Type": "application/json"}, + json={ + "account": email, + "password": password, + } + ) + + data = response.json() + + if response.status_code == 200: + # Check if we need verification code + # Bambu API returns loginType or may require tfaKey + if data.get("loginType") == "verifyCode" or "tfaKey" in data: + return { + "success": False, + "needs_verification": True, + "message": "Verification code sent to email" + } + + # Direct login success (rare, usually needs 2FA) + if "accessToken" in data: + self._set_tokens(data) + return { + "success": True, + "needs_verification": False, + "message": "Login successful" + } + + # Handle specific error codes + error_msg = data.get("message") or data.get("error") or "Login failed" + return { + "success": False, + "needs_verification": False, + "message": error_msg + } + + except Exception as e: + logger.error(f"Login request failed: {e}") + raise BambuCloudAuthError(f"Login request failed: {e}") + + async def verify_code(self, email: str, code: str) -> dict: + """ + Complete login with verification code. + """ + try: + response = await self._client.post( + f"{self.base_url}/v1/user-service/user/login", + headers={"Content-Type": "application/json"}, + json={ + "account": email, + "code": code, + } + ) + + data = response.json() + + if response.status_code == 200 and "accessToken" in data: + self._set_tokens(data) + return { + "success": True, + "message": "Login successful" + } + + return { + "success": False, + "message": data.get("message", "Verification failed") + } + + except Exception as e: + logger.error(f"Verification failed: {e}") + raise BambuCloudAuthError(f"Verification failed: {e}") + + def _set_tokens(self, data: dict): + """Set tokens from login response.""" + self.access_token = data.get("accessToken") + self.refresh_token = data.get("refreshToken") + # Token typically valid for ~3 months, but we'll refresh more often + self.token_expiry = datetime.now() + timedelta(days=30) + + def set_token(self, access_token: str): + """Set access token directly (for stored tokens).""" + self.access_token = access_token + self.token_expiry = datetime.now() + timedelta(days=30) + + def logout(self): + """Clear authentication state.""" + self.access_token = None + self.refresh_token = None + self.token_expiry = None + + async def get_user_profile(self) -> dict: + """Get user profile information.""" + if not self.is_authenticated: + raise BambuCloudAuthError("Not authenticated") + + try: + response = await self._client.get( + f"{self.base_url}/v1/design-user-service/my/preference", + headers=self._get_headers() + ) + + if response.status_code == 200: + return response.json() + + raise BambuCloudError(f"Failed to get profile: {response.status_code}") + + except httpx.RequestError as e: + raise BambuCloudError(f"Request failed: {e}") + + async def get_slicer_settings(self, version: str = "01.09.00.00") -> dict: + """ + Get all slicer settings (filament, printer, process presets). + + Args: + version: Slicer version string + """ + if not self.is_authenticated: + raise BambuCloudAuthError("Not authenticated") + + try: + response = await self._client.get( + f"{self.base_url}/v1/iot-service/api/slicer/setting", + headers=self._get_headers(), + params={"version": version} + ) + + data = response.json() + + if response.status_code == 200: + return data + + raise BambuCloudError(f"Failed to get settings: {response.status_code}") + + except httpx.RequestError as e: + raise BambuCloudError(f"Request failed: {e}") + + async def get_setting_detail(self, setting_id: str) -> dict: + """Get detailed information for a specific setting/preset.""" + if not self.is_authenticated: + raise BambuCloudAuthError("Not authenticated") + + try: + response = await self._client.get( + f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}", + headers=self._get_headers() + ) + + if response.status_code == 200: + return response.json() + + raise BambuCloudError(f"Failed to get setting detail: {response.status_code}") + + except httpx.RequestError as e: + raise BambuCloudError(f"Request failed: {e}") + + async def get_devices(self) -> dict: + """Get list of bound devices.""" + if not self.is_authenticated: + raise BambuCloudAuthError("Not authenticated") + + try: + response = await self._client.get( + f"{self.base_url}/v1/iot-service/api/user/bind", + headers=self._get_headers() + ) + + if response.status_code == 200: + return response.json() + + raise BambuCloudError(f"Failed to get devices: {response.status_code}") + + except httpx.RequestError as e: + raise BambuCloudError(f"Request failed: {e}") + + async def close(self): + """Close the HTTP client.""" + await self._client.aclose() + + +# Singleton instance +_cloud_service: Optional[BambuCloudService] = None + + +def get_cloud_service() -> BambuCloudService: + """Get the singleton cloud service instance.""" + global _cloud_service + if _cloud_service is None: + _cloud_service = BambuCloudService() + return _cloud_service diff --git a/backend/app/services/bambu_ftp.py b/backend/app/services/bambu_ftp.py new file mode 100644 index 000000000..eb0b4812b --- /dev/null +++ b/backend/app/services/bambu_ftp.py @@ -0,0 +1,388 @@ +import ssl +import socket +import asyncio +import logging +from ftplib import FTP_TLS, FTP +from pathlib import Path +from io import BytesIO + +logger = logging.getLogger(__name__) + + +class ImplicitFTP_TLS(FTP_TLS): + """FTP_TLS subclass for implicit FTPS (port 990) with session reuse.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._sock = None + self.ssl_context = ssl.create_default_context() + self.ssl_context.check_hostname = False + self.ssl_context.verify_mode = ssl.CERT_NONE + + def connect(self, host='', port=990, timeout=-999, source_address=None): + """Connect to host, wrapping socket in TLS immediately (implicit FTPS).""" + if host: + self.host = host + if port > 0: + self.port = port + if timeout != -999: + self.timeout = timeout + if source_address: + self.source_address = source_address + + # Create and wrap socket immediately (implicit TLS) + self.sock = socket.create_connection( + (self.host, self.port), + self.timeout, + source_address=self.source_address + ) + self.sock = self.ssl_context.wrap_socket(self.sock, server_hostname=self.host) + self.af = self.sock.family + self.file = self.sock.makefile('r', encoding=self.encoding) + self.welcome = self.getresp() + return self.welcome + + def ntransfercmd(self, cmd, rest=None): + """Override to reuse SSL session for data connection (required by vsFTPd).""" + conn, size = FTP.ntransfercmd(self, cmd, rest) + if self._prot_p: + # Reuse the SSL session from the control connection + conn = self.ssl_context.wrap_socket( + conn, + server_hostname=self.host, + session=self.sock.session # Reuse session! + ) + return conn, size + + + +class BambuFTPClient: + """FTP client for retrieving files from Bambu Lab printers.""" + + FTP_PORT = 990 + + def __init__(self, ip_address: str, access_code: str): + self.ip_address = ip_address + self.access_code = access_code + self._ftp: ImplicitFTP_TLS | None = None + + def connect(self) -> bool: + """Connect to the printer FTP server (implicit FTPS on port 990).""" + try: + self._ftp = ImplicitFTP_TLS() + self._ftp.connect(self.ip_address, self.FTP_PORT, timeout=10) + self._ftp.login("bblp", self.access_code) + self._ftp.prot_p() + self._ftp.set_pasv(True) + return True + except Exception as e: + logger.warning(f"FTP connection failed to {self.ip_address}: {e}") + self._ftp = None + return False + + def disconnect(self): + """Disconnect from the FTP server.""" + if self._ftp: + try: + self._ftp.quit() + except Exception: + pass + self._ftp = None + + def list_files(self, path: str = "/") -> list[dict]: + """List files in a directory.""" + if not self._ftp: + return [] + + files = [] + try: + self._ftp.cwd(path) + items = [] + self._ftp.retrlines("LIST", items.append) + + for item in items: + parts = item.split() + if len(parts) >= 9: + name = " ".join(parts[8:]) + is_dir = item.startswith("d") + size = int(parts[4]) if not is_dir else 0 + files.append({ + "name": name, + "is_directory": is_dir, + "size": size, + }) + except Exception: + pass + + return files + + def download_file(self, remote_path: str) -> bytes | None: + """Download a file from the printer.""" + if not self._ftp: + return None + + try: + buffer = BytesIO() + self._ftp.retrbinary(f"RETR {remote_path}", buffer.write) + return buffer.getvalue() + except Exception: + return None + + def download_to_file(self, remote_path: str, local_path: Path) -> bool: + """Download a file from the printer to local filesystem.""" + if not self._ftp: + logger.warning("download_to_file called but FTP not connected") + return False + + try: + local_path.parent.mkdir(parents=True, exist_ok=True) + with open(local_path, "wb") as f: + self._ftp.retrbinary(f"RETR {remote_path}", f.write) + logger.info(f"Successfully downloaded {remote_path} to {local_path}") + return True + except Exception as e: + logger.debug(f"Failed to download {remote_path}: {e}") + # Clean up partial file if it exists + if local_path.exists(): + try: + local_path.unlink() + except Exception: + pass + return False + + def upload_file(self, local_path: Path, remote_path: str) -> bool: + """Upload a file to the printer.""" + if not self._ftp: + return False + + try: + with open(local_path, "rb") as f: + self._ftp.storbinary(f"STOR {remote_path}", f) + return True + except Exception: + return False + + def upload_bytes(self, data: bytes, remote_path: str) -> bool: + """Upload bytes to the printer.""" + if not self._ftp: + return False + + try: + buffer = BytesIO(data) + self._ftp.storbinary(f"STOR {remote_path}", buffer) + return True + except Exception: + return False + + def delete_file(self, remote_path: str) -> bool: + """Delete a file from the printer.""" + if not self._ftp: + return False + + try: + self._ftp.delete(remote_path) + return True + except Exception as e: + logger.warning(f"Failed to delete {remote_path}: {e}") + return False + + def get_file_size(self, remote_path: str) -> int | None: + """Get the size of a file.""" + if not self._ftp: + return None + + try: + return self._ftp.size(remote_path) + except Exception: + return None + + def get_storage_info(self) -> dict | None: + """Get storage information from the printer.""" + if not self._ftp: + return None + + result = {} + + # Try AVBL command (available space) - some FTP servers support this + try: + response = self._ftp.sendcmd("AVBL") + # Response format: "213 " + if response.startswith("213"): + parts = response.split() + if len(parts) >= 2: + result["free_bytes"] = int(parts[1]) + except Exception: + pass + + # Calculate used space by listing root directories + try: + total_used = 0 + dirs_to_scan = ["/cache", "/timelapse", "/model"] + + for dir_path in dirs_to_scan: + try: + self._ftp.cwd(dir_path) + items = [] + self._ftp.retrlines("LIST", items.append) + + for item in items: + parts = item.split() + if len(parts) >= 5 and not item.startswith("d"): + try: + total_used += int(parts[4]) + except ValueError: + pass + except Exception: + pass + + result["used_bytes"] = total_used + except Exception: + pass + + return result if result else None + + +async def download_file_async( + ip_address: str, + access_code: str, + remote_path: str, + local_path: Path, +) -> bool: + """Async wrapper for downloading a file.""" + loop = asyncio.get_event_loop() + + def _download(): + client = BambuFTPClient(ip_address, access_code) + if client.connect(): + try: + return client.download_to_file(remote_path, local_path) + finally: + client.disconnect() + return False + + return await loop.run_in_executor(None, _download) + + +async def download_file_try_paths_async( + ip_address: str, + access_code: str, + remote_paths: list[str], + local_path: Path, +) -> bool: + """Try downloading a file from multiple paths using a single connection.""" + loop = asyncio.get_event_loop() + + def _download(): + client = BambuFTPClient(ip_address, access_code) + if not client.connect(): + return False + + try: + for remote_path in remote_paths: + if client.download_to_file(remote_path, local_path): + return True + return False + finally: + client.disconnect() + + return await loop.run_in_executor(None, _download) + + +async def upload_file_async( + ip_address: str, + access_code: str, + local_path: Path, + remote_path: str, +) -> bool: + """Async wrapper for uploading a file.""" + loop = asyncio.get_event_loop() + + def _upload(): + client = BambuFTPClient(ip_address, access_code) + if client.connect(): + try: + return client.upload_file(local_path, remote_path) + finally: + client.disconnect() + return False + + return await loop.run_in_executor(None, _upload) + + +async def list_files_async( + ip_address: str, + access_code: str, + path: str = "/", +) -> list[dict]: + """Async wrapper for listing files.""" + loop = asyncio.get_event_loop() + + def _list(): + client = BambuFTPClient(ip_address, access_code) + if client.connect(): + try: + return client.list_files(path) + finally: + client.disconnect() + return [] + + return await loop.run_in_executor(None, _list) + + +async def delete_file_async( + ip_address: str, + access_code: str, + remote_path: str, +) -> bool: + """Async wrapper for deleting a file.""" + loop = asyncio.get_event_loop() + + def _delete(): + client = BambuFTPClient(ip_address, access_code) + if client.connect(): + try: + return client.delete_file(remote_path) + finally: + client.disconnect() + return False + + return await loop.run_in_executor(None, _delete) + + +async def download_file_bytes_async( + ip_address: str, + access_code: str, + remote_path: str, +) -> bytes | None: + """Async wrapper for downloading file as bytes.""" + loop = asyncio.get_event_loop() + + def _download(): + client = BambuFTPClient(ip_address, access_code) + if client.connect(): + try: + return client.download_file(remote_path) + finally: + client.disconnect() + return None + + return await loop.run_in_executor(None, _download) + + +async def get_storage_info_async( + ip_address: str, + access_code: str, +) -> dict | None: + """Async wrapper for getting storage info.""" + loop = asyncio.get_event_loop() + + def _get_storage(): + client = BambuFTPClient(ip_address, access_code) + if client.connect(): + try: + return client.get_storage_info() + finally: + client.disconnect() + return None + + return await loop.run_in_executor(None, _get_storage) diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py new file mode 100644 index 000000000..9384f6177 --- /dev/null +++ b/backend/app/services/bambu_mqtt.py @@ -0,0 +1,234 @@ +import json +import ssl +import asyncio +from typing import Callable +from dataclasses import dataclass, field + +import paho.mqtt.client as mqtt + + +@dataclass +class PrinterState: + connected: bool = False + state: str = "unknown" + current_print: str | None = None + subtask_name: str | None = None + progress: float = 0.0 + remaining_time: int = 0 + layer_num: int = 0 + total_layers: int = 0 + temperatures: dict = field(default_factory=dict) + raw_data: dict = field(default_factory=dict) + gcode_file: str | None = None + subtask_id: str | None = None + + +class BambuMQTTClient: + """MQTT client for Bambu Lab printer communication.""" + + MQTT_PORT = 8883 + + def __init__( + self, + ip_address: str, + serial_number: str, + access_code: str, + on_state_change: Callable[[PrinterState], None] | None = None, + on_print_start: Callable[[dict], None] | None = None, + on_print_complete: Callable[[dict], None] | None = None, + ): + self.ip_address = ip_address + self.serial_number = serial_number + self.access_code = access_code + self.on_state_change = on_state_change + self.on_print_start = on_print_start + self.on_print_complete = on_print_complete + + self.state = PrinterState() + self._client: mqtt.Client | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._previous_gcode_state: str | None = None + self._previous_gcode_file: str | None = None + + @property + def topic_subscribe(self) -> str: + return f"device/{self.serial_number}/report" + + @property + def topic_publish(self) -> str: + return f"device/{self.serial_number}/request" + + def _on_connect(self, client, userdata, flags, rc, properties=None): + if rc == 0: + self.state.connected = True + client.subscribe(self.topic_subscribe) + # Request full status update + self._request_push_all() + else: + self.state.connected = False + + def _on_disconnect(self, client, userdata, disconnect_flags=None, rc=None, properties=None): + self.state.connected = False + if self.on_state_change: + self.on_state_change(self.state) + + def _on_message(self, client, userdata, msg): + try: + payload = json.loads(msg.payload.decode()) + self._process_message(payload) + except json.JSONDecodeError: + pass + + def _process_message(self, payload: dict): + """Process incoming MQTT message from printer.""" + if "print" in payload: + print_data = payload["print"] + self._update_state(print_data) + + def _update_state(self, data: dict): + """Update printer state from message data.""" + previous_state = self.state.state + + # Update state fields + if "gcode_state" in data: + self.state.state = data["gcode_state"] + if "gcode_file" in data: + self.state.gcode_file = data["gcode_file"] + self.state.current_print = data["gcode_file"] + if "subtask_name" in data: + self.state.subtask_name = data["subtask_name"] + # Prefer subtask_name as current_print if available + if data["subtask_name"]: + self.state.current_print = data["subtask_name"] + if "subtask_id" in data: + self.state.subtask_id = data["subtask_id"] + if "mc_percent" in data: + self.state.progress = float(data["mc_percent"]) + if "mc_remaining_time" in data: + self.state.remaining_time = int(data["mc_remaining_time"]) + if "layer_num" in data: + self.state.layer_num = int(data["layer_num"]) + if "total_layer_num" in data: + self.state.total_layers = int(data["total_layer_num"]) + + # Temperature data + temps = {} + if "bed_temper" in data: + temps["bed"] = float(data["bed_temper"]) + if "bed_target_temper" in data: + temps["bed_target"] = float(data["bed_target_temper"]) + if "nozzle_temper" in data: + temps["nozzle"] = float(data["nozzle_temper"]) + if "nozzle_target_temper" in data: + temps["nozzle_target"] = float(data["nozzle_target_temper"]) + if "chamber_temper" in data: + temps["chamber"] = float(data["chamber_temper"]) + if temps: + self.state.temperatures = temps + + self.state.raw_data = data + + # Detect print start (state changes TO RUNNING with a file) + current_file = self.state.gcode_file or self.state.current_print + is_new_print = ( + self.state.state == "RUNNING" + and self._previous_gcode_state != "RUNNING" + and current_file + ) + # Also detect if file changed while running (new print started) + is_file_change = ( + self.state.state == "RUNNING" + and current_file + and current_file != self._previous_gcode_file + and self._previous_gcode_file is not None + ) + + if (is_new_print or is_file_change) and self.on_print_start: + self.on_print_start({ + "filename": current_file, + "subtask_name": self.state.subtask_name, + "raw_data": data, + }) + + # Detect print completion + if ( + self._previous_gcode_state == "RUNNING" + and self.state.state in ("FINISH", "FAILED") + and self.on_print_complete + ): + self.on_print_complete({ + "status": "completed" if self.state.state == "FINISH" else "failed", + "filename": self._previous_gcode_file or current_file, + "raw_data": data, + }) + + self._previous_gcode_state = self.state.state + if current_file: + self._previous_gcode_file = current_file + + if self.on_state_change: + self.on_state_change(self.state) + + def _request_push_all(self): + """Request full status update from printer.""" + if self._client: + message = {"pushing": {"command": "pushall"}} + self._client.publish(self.topic_publish, json.dumps(message)) + + def connect(self): + """Connect to the printer MQTT broker.""" + self._client = mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=f"bambutrack_{self.serial_number}", + protocol=mqtt.MQTTv311, + ) + + self._client.username_pw_set("bblp", self.access_code) + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + # TLS setup - Bambu uses self-signed certs + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + self._client.tls_set_context(ssl_context) + + self._client.connect_async(self.ip_address, self.MQTT_PORT) + self._client.loop_start() + + def start_print(self, filename: str, plate_id: int = 1): + """Start a print job on the printer.""" + if self._client and self.state.connected: + # Bambu print command format + command = { + "print": { + "command": "project_file", + "param": f"Metadata/plate_{plate_id}.gcode", + "subtask_name": filename, + "url": f"ftp://{filename}", + "bed_type": "auto", + "timelapse": False, + "bed_leveling": True, + "flow_cali": True, + "vibration_cali": True, + "layer_inspect": False, + "use_ams": True, + } + } + self._client.publish(self.topic_publish, json.dumps(command)) + return True + return False + + def disconnect(self): + """Disconnect from the printer.""" + if self._client: + self._client.loop_stop() + self._client.disconnect() + self._client = None + self.state.connected = False + + def send_command(self, command: dict): + """Send a command to the printer.""" + if self._client and self.state.connected: + self._client.publish(self.topic_publish, json.dumps(command)) diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py new file mode 100644 index 000000000..3d629501f --- /dev/null +++ b/backend/app/services/printer_manager.py @@ -0,0 +1,183 @@ +import asyncio +from typing import Callable +from dataclasses import asdict + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.models.printer import Printer +from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState +from backend.app.services.bambu_ftp import BambuFTPClient + + +class PrinterManager: + """Manager for multiple printer connections.""" + + def __init__(self): + self._clients: dict[int, BambuMQTTClient] = {} + self._on_print_start: Callable[[int, dict], None] | None = None + self._on_print_complete: Callable[[int, dict], None] | None = None + self._on_status_change: Callable[[int, PrinterState], None] | None = None + self._loop: asyncio.AbstractEventLoop | None = None + + def set_event_loop(self, loop: asyncio.AbstractEventLoop): + """Set the event loop for async callbacks.""" + self._loop = loop + + def set_print_start_callback(self, callback: Callable[[int, dict], None]): + """Set callback for print start events.""" + self._on_print_start = callback + + def set_print_complete_callback(self, callback: Callable[[int, dict], None]): + """Set callback for print completion events.""" + self._on_print_complete = callback + + def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]): + """Set callback for status change events.""" + self._on_status_change = callback + + def _schedule_async(self, coro): + """Schedule an async coroutine from a sync context.""" + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(coro, self._loop) + + async def connect_printer(self, printer: Printer) -> bool: + """Connect to a printer.""" + if printer.id in self._clients: + self.disconnect_printer(printer.id) + + printer_id = printer.id + + def on_state_change(state: PrinterState): + if self._on_status_change: + self._schedule_async( + self._on_status_change(printer_id, state) + ) + + def on_print_start(data: dict): + if self._on_print_start: + self._schedule_async( + self._on_print_start(printer_id, data) + ) + + def on_print_complete(data: dict): + if self._on_print_complete: + self._schedule_async( + self._on_print_complete(printer_id, data) + ) + + client = BambuMQTTClient( + ip_address=printer.ip_address, + serial_number=printer.serial_number, + access_code=printer.access_code, + on_state_change=on_state_change, + on_print_start=on_print_start, + on_print_complete=on_print_complete, + ) + + client.connect() + self._clients[printer_id] = client + + # Wait a moment for connection + await asyncio.sleep(1) + return client.state.connected + + def disconnect_printer(self, printer_id: int): + """Disconnect from a printer.""" + if printer_id in self._clients: + self._clients[printer_id].disconnect() + del self._clients[printer_id] + + def disconnect_all(self): + """Disconnect from all printers.""" + for printer_id in list(self._clients.keys()): + self.disconnect_printer(printer_id) + + def get_status(self, printer_id: int) -> PrinterState | None: + """Get the current status of a printer.""" + if printer_id in self._clients: + return self._clients[printer_id].state + return None + + def get_all_statuses(self) -> dict[int, PrinterState]: + """Get status of all connected printers.""" + return { + printer_id: client.state + for printer_id, client in self._clients.items() + } + + def is_connected(self, printer_id: int) -> bool: + """Check if a printer is connected.""" + if printer_id in self._clients: + return self._clients[printer_id].state.connected + return False + + def start_print(self, printer_id: int, filename: str) -> bool: + """Start a print on a connected printer.""" + if printer_id in self._clients: + return self._clients[printer_id].start_print(filename) + return False + + async def test_connection( + self, + ip_address: str, + serial_number: str, + access_code: str, + ) -> dict: + """Test connection to a printer without persisting.""" + client = BambuMQTTClient( + ip_address=ip_address, + serial_number=serial_number, + access_code=access_code, + ) + + try: + client.connect() + await asyncio.sleep(2) + + result = { + "success": client.state.connected, + "state": client.state.state if client.state.connected else None, + "model": client.state.raw_data.get("device_model"), + } + finally: + client.disconnect() + + return result + + +def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> dict: + """Convert PrinterState to a JSON-serializable dict.""" + result = { + "connected": state.connected, + "state": state.state, + "current_print": state.current_print, + "subtask_name": state.subtask_name, + "gcode_file": state.gcode_file, + "progress": state.progress, + "remaining_time": state.remaining_time, + "layer_num": state.layer_num, + "total_layers": state.total_layers, + "temperatures": state.temperatures, + } + # Add cover URL if there's an active print and printer_id is provided + if printer_id and state.state == "RUNNING" and state.gcode_file: + result["cover_url"] = f"/api/v1/printers/{printer_id}/cover" + else: + result["cover_url"] = None + return result + + +# Global printer manager instance +printer_manager = PrinterManager() + + +async def init_printer_connections(db: AsyncSession): + """Initialize connections to all active printers.""" + result = await db.execute( + select(Printer).where(Printer.is_active == True) + ) + printers = result.scalars().all() + + for printer in printers: + await printer_manager.connect_printer(printer) diff --git a/docs/screenshots/3d-preview.png b/docs/screenshots/3d-preview.png new file mode 100644 index 000000000..777159486 Binary files /dev/null and b/docs/screenshots/3d-preview.png differ diff --git a/docs/screenshots/archives.png b/docs/screenshots/archives.png new file mode 100644 index 000000000..e9061ef8b Binary files /dev/null and b/docs/screenshots/archives.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 000000000..f690580b1 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/printers.png b/docs/screenshots/printers.png new file mode 100644 index 000000000..c078d7997 Binary files /dev/null and b/docs/screenshots/printers.png differ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 000000000..d2e77611f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 000000000..5e6b472f5 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..7f4bed61f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Bambusy + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..bbeb58e8f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4679 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.90.11", + "@types/three": "^0.181.0", + "gcode-preview": "^2.18.0", + "jszip": "^3.10.1", + "lucide-react": "^0.555.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6", + "recharts": "^3.5.1", + "three": "^0.181.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz", + "integrity": "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", + "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", + "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.11", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", + "integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz", + "integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.181.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz", + "integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", + "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/type-utils": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.48.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", + "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", + "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.48.0", + "@typescript-eslint/types": "^8.48.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", + "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", + "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", + "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", + "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", + "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.48.0", + "@typescript-eslint/tsconfig-utils": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/visitor-keys": "8.48.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", + "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.48.0", + "@typescript-eslint/types": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", + "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.48.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.66", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", + "integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==", + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", + "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001757", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", + "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.262", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", + "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", + "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gcode-preview": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/gcode-preview/-/gcode-preview-2.18.0.tgz", + "integrity": "sha512-uc9QYciG6ES/A6BWJpUZk4fHxCPvt5EnvDhHIHDbNdR/m3f9VkGvpSMh9HDygXjAXX0x1Lbz/e9ZGlIrYNB29A==", + "license": "MIT", + "dependencies": { + "lil-gui": "^0.19.2", + "three": "^0.159.0" + } + }, + "node_modules/gcode-preview/node_modules/three": { + "version": "0.159.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.159.0.tgz", + "integrity": "sha512-eCmhlLGbBgucuo4VEA9IO3Qpc7dh8Bd4VKzr7WfW4+8hMcIfoAVi1ev0pJYN9PTTsCslbcKgBwr2wNZ1EvLInA==", + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lil-gui": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.19.2.tgz", + "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", + "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.6.tgz", + "integrity": "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.6.tgz", + "integrity": "sha512-2MkC2XSXq6HjGcihnx1s0DBWQETI4mlis4Ux7YTLvP67xnGxCvq+BcCQSO81qQHVUTM1V53tl4iVVaY5sReCOA==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.6" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/recharts": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz", + "integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.181.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz", + "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.48.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz", + "integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.48.0", + "@typescript-eslint/parser": "8.48.0", + "@typescript-eslint/typescript-estree": "8.48.0", + "@typescript-eslint/utils": "8.48.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", + "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..f66791dfa --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.90.11", + "@types/three": "^0.181.0", + "gcode-preview": "^2.18.0", + "jszip": "^3.10.1", + "lucide-react": "^0.555.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.9.6", + "recharts": "^3.5.1", + "three": "^0.181.2" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.17", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..a7f73a2d1 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/frontend/public/img/bambusy_logo_dark.png b/frontend/public/img/bambusy_logo_dark.png new file mode 100644 index 000000000..d026dd6ff Binary files /dev/null and b/frontend/public/img/bambusy_logo_dark.png differ diff --git a/frontend/public/img/bambusy_logo_light.png b/frontend/public/img/bambusy_logo_light.png new file mode 100644 index 000000000..13311f9e0 Binary files /dev/null and b/frontend/public/img/bambusy_logo_light.png differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 000000000..b9d355df2 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..7ea7d6b76 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,51 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Layout } from './components/Layout'; +import { PrintersPage } from './pages/PrintersPage'; +import { ArchivesPage } from './pages/ArchivesPage'; +import { StatsPage } from './pages/StatsPage'; +import { SettingsPage } from './pages/SettingsPage'; +import { CloudProfilesPage } from './pages/CloudProfilesPage'; +import { useWebSocket } from './hooks/useWebSocket'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { ToastProvider } from './contexts/ToastContext'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: 1, + }, + }, +}); + +function WebSocketProvider({ children }: { children: React.ReactNode }) { + useWebSocket(); + return <>{children}; +} + +function App() { + return ( + + + + + + + }> + } /> + } /> + } /> + } /> + } /> + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 000000000..4b7a0e0bd --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,363 @@ +const API_BASE = '/api/v1'; + +async function request( + endpoint: string, + options: RequestInit = {} +): Promise { + const response = await fetch(`${API_BASE}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return response.json(); +} + +// Printer types +export interface Printer { + id: number; + name: string; + serial_number: string; + ip_address: string; + access_code: string; + model: string | null; + is_active: boolean; + auto_archive: boolean; + created_at: string; + updated_at: string; +} + +export interface PrinterStatus { + id: number; + name: string; + connected: boolean; + state: string | null; + current_print: string | null; + subtask_name: string | null; + gcode_file: string | null; + progress: number | null; + remaining_time: number | null; + layer_num: number | null; + total_layers: number | null; + temperatures: { + bed?: number; + bed_target?: number; + nozzle?: number; + nozzle_target?: number; + chamber?: number; + } | null; + cover_url: string | null; +} + +export interface PrinterCreate { + name: string; + serial_number: string; + ip_address: string; + access_code: string; + model?: string; + auto_archive?: boolean; +} + +// Archive types +export interface Archive { + id: number; + printer_id: number | null; + filename: string; + file_path: string; + file_size: number; + thumbnail_path: string | null; + timelapse_path: string | null; + print_name: string | null; + print_time_seconds: number | null; + filament_used_grams: number | null; + filament_type: string | null; + filament_color: string | null; + layer_height: number | null; + nozzle_diameter: number | null; + bed_temperature: number | null; + nozzle_temperature: number | null; + status: string; + started_at: string | null; + completed_at: string | null; + extra_data: Record | null; + makerworld_url: string | null; + designer: string | null; + is_favorite: boolean; + tags: string | null; + notes: string | null; + cost: number | null; + photos: string[] | null; + failure_reason: string | null; + created_at: string; +} + +export interface ArchiveStats { + total_prints: number; + successful_prints: number; + failed_prints: number; + total_print_time_hours: number; + total_filament_grams: number; + total_cost: number; + prints_by_filament_type: Record; + prints_by_printer: Record; +} + +export interface BulkUploadResult { + uploaded: number; + failed: number; + results: Array<{ filename: string; id: number; status: string }>; + errors: Array<{ filename: string; error: string }>; +} + +// Settings types +export interface AppSettings { + auto_archive: boolean; + save_thumbnails: boolean; + default_filament_cost: number; + currency: string; +} + +export type AppSettingsUpdate = Partial; + +// Cloud types +export interface CloudAuthStatus { + is_authenticated: boolean; + email: string | null; +} + +export interface CloudLoginResponse { + success: boolean; + needs_verification: boolean; + message: string; +} + +export interface SlicerSetting { + setting_id: string; + name: string; + type: string; + version: string | null; + user_id: string | null; + updated_time: string | null; +} + +export interface SlicerSettingsResponse { + filament: SlicerSetting[]; + printer: SlicerSetting[]; + process: SlicerSetting[]; +} + +export interface CloudDevice { + dev_id: string; + name: string; + dev_model_name: string | null; + dev_product_name: string | null; + online: boolean; +} + +// API functions +export const api = { + // Printers + getPrinters: () => request('/printers/'), + getPrinter: (id: number) => request(`/printers/${id}`), + createPrinter: (data: PrinterCreate) => + request('/printers/', { + method: 'POST', + body: JSON.stringify(data), + }), + updatePrinter: (id: number, data: Partial) => + request(`/printers/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deletePrinter: (id: number) => + request(`/printers/${id}`, { method: 'DELETE' }), + getPrinterStatus: (id: number) => + request(`/printers/${id}/status`), + connectPrinter: (id: number) => + request<{ connected: boolean }>(`/printers/${id}/connect`, { + method: 'POST', + }), + disconnectPrinter: (id: number) => + request<{ connected: boolean }>(`/printers/${id}/disconnect`, { + method: 'POST', + }), + + // Printer File Manager + getPrinterFiles: (printerId: number, path = '/') => + request<{ + path: string; + files: Array<{ + name: string; + is_directory: boolean; + size: number; + path: string; + }>; + }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`), + getPrinterFileDownloadUrl: (printerId: number, path: string) => + `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`, + deletePrinterFile: (printerId: number, path: string) => + request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, { + method: 'DELETE', + }), + getPrinterStorage: (printerId: number) => + request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`), + + // Archives + getArchives: (printerId?: number, limit = 50, offset = 0) => { + const params = new URLSearchParams(); + if (printerId) params.set('printer_id', String(printerId)); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + return request(`/archives/?${params}`); + }, + getArchive: (id: number) => request(`/archives/${id}`), + updateArchive: (id: number, data: { + printer_id?: number | null; + print_name?: string; + is_favorite?: boolean; + tags?: string; + notes?: string; + cost?: number; + failure_reason?: string | null; + }) => + request(`/archives/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + toggleFavorite: (id: number) => + request(`/archives/${id}/favorite`, { method: 'POST' }), + deleteArchive: (id: number) => + request(`/archives/${id}`, { method: 'DELETE' }), + getArchiveStats: () => request('/archives/stats'), + getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`, + getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`, + getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`, + getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse`, + scanArchiveTimelapse: (id: number) => + request<{ status: string; message: string; filename?: string }>(`/archives/${id}/timelapse/scan`, { + method: 'POST', + }), + uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => { + const formData = new FormData(); + formData.append('file', file); + const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, { + method: 'POST', + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + return response.json(); + }, + // Photos + getArchivePhotoUrl: (archiveId: number, filename: string) => + `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, + uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => { + const formData = new FormData(); + formData.append('file', file); + const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, { + method: 'POST', + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + return response.json(); + }, + deleteArchivePhoto: (archiveId: number, filename: string) => + request<{ status: string; photos: string[] | null }>(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, { + method: 'DELETE', + }), + // QR Code + getArchiveQRCodeUrl: (archiveId: number, size = 200) => + `${API_BASE}/archives/${archiveId}/qrcode?size=${size}`, + getArchiveCapabilities: (id: number) => + request<{ + has_model: boolean; + has_gcode: boolean; + build_volume: { x: number; y: number; z: number }; + }>(`/archives/${id}/capabilities`), + getArchiveForSlicer: (id: number, filename: string) => + `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`, + reprintArchive: (archiveId: number, printerId: number) => + request<{ status: string; printer_id: number; archive_id: number; filename: string }>( + `/archives/${archiveId}/reprint?printer_id=${printerId}`, + { method: 'POST' } + ), + uploadArchive: async (file: File, printerId?: number): Promise => { + const formData = new FormData(); + formData.append('file', file); + const url = printerId + ? `${API_BASE}/archives/upload?printer_id=${printerId}` + : `${API_BASE}/archives/upload`; + const response = await fetch(url, { + method: 'POST', + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + return response.json(); + }, + uploadArchivesBulk: async (files: File[], printerId?: number): Promise => { + const formData = new FormData(); + files.forEach((file) => formData.append('files', file)); + const url = printerId + ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}` + : `${API_BASE}/archives/upload-bulk`; + const response = await fetch(url, { + method: 'POST', + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + return response.json(); + }, + + // Settings + getSettings: () => request('/settings/'), + updateSettings: (data: AppSettingsUpdate) => + request('/settings/', { + method: 'PUT', + body: JSON.stringify(data), + }), + resetSettings: () => + request('/settings/reset', { method: 'POST' }), + + // Cloud + getCloudStatus: () => request('/cloud/status'), + cloudLogin: (email: string, password: string, region = 'global') => + request('/cloud/login', { + method: 'POST', + body: JSON.stringify({ email, password, region }), + }), + cloudVerify: (email: string, code: string) => + request('/cloud/verify', { + method: 'POST', + body: JSON.stringify({ email, code }), + }), + cloudSetToken: (access_token: string) => + request('/cloud/token', { + method: 'POST', + body: JSON.stringify({ access_token }), + }), + cloudLogout: () => + request<{ success: boolean }>('/cloud/logout', { method: 'POST' }), + getCloudSettings: (version = '01.09.00.00') => + request(`/cloud/settings?version=${version}`), + getCloudSettingDetail: (settingId: string) => + request>(`/cloud/settings/${settingId}`), + getCloudDevices: () => request('/cloud/devices'), +}; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/BatchTagModal.tsx b/frontend/src/components/BatchTagModal.tsx new file mode 100644 index 000000000..0dc9f6d97 --- /dev/null +++ b/frontend/src/components/BatchTagModal.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { X, Tag, Plus, Loader2 } from 'lucide-react'; +import { api } from '../api/client'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; + +interface BatchTagModalProps { + selectedIds: number[]; + existingTags: string[]; + onClose: () => void; +} + +export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const [newTag, setNewTag] = useState(''); + const [selectedTags, setSelectedTags] = useState>(new Set()); + const [mode, setMode] = useState<'add' | 'remove'>('add'); + + const batchTagMutation = useMutation({ + mutationFn: async () => { + const tagsArray = Array.from(selectedTags); + await Promise.all( + selectedIds.map(async (id) => { + const archive = await api.getArchive(id); + const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : []; + + let newTags: string[]; + if (mode === 'add') { + // Add tags that aren't already present + newTags = [...new Set([...currentTags, ...tagsArray])]; + } else { + // Remove selected tags + newTags = currentTags.filter(t => !selectedTags.has(t)); + } + + return api.updateArchive(id, { tags: newTags.join(', ') }); + }) + ); + return { count: selectedIds.length, mode, tags: tagsArray }; + }, + onSuccess: ({ count, mode, tags }) => { + queryClient.invalidateQueries({ queryKey: ['archives'] }); + showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`); + onClose(); + }, + onError: () => { + showToast('Failed to update tags', 'error'); + }, + }); + + const toggleTag = (tag: string) => { + setSelectedTags((prev) => { + const next = new Set(prev); + if (next.has(tag)) { + next.delete(tag); + } else { + next.add(tag); + } + return next; + }); + }; + + const addNewTag = () => { + if (newTag.trim() && !selectedTags.has(newTag.trim())) { + setSelectedTags((prev) => new Set([...prev, newTag.trim()])); + setNewTag(''); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + addNewTag(); + } + }; + + return ( +
+ + + {/* Header */} +
+
+ +

+ {mode === 'add' ? 'Add Tags' : 'Remove Tags'} +

+
+ +
+ + {/* Content */} +
+

+ {mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''} +

+ + {/* Mode toggle */} +
+ + +
+ + {/* New tag input (only for add mode) */} + {mode === 'add' && ( +
+ setNewTag(e.target.value)} + onKeyDown={handleKeyDown} + /> + +
+ )} + + {/* Existing tags */} + {existingTags.length > 0 && ( +
+

Existing tags:

+
+ {existingTags.map((tag) => ( + + ))} +
+
+ )} + + {/* Selected tags preview */} + {selectedTags.size > 0 && ( +
+

+ Tags to {mode === 'add' ? 'add' : 'remove'}: +

+
+ {Array.from(selectedTags).map((tag) => ( + + {tag} + + + ))} +
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 000000000..fe6f106e3 --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; +} + +export function Button({ + variant = 'primary', + size = 'md', + className = '', + children, + ...props +}: ButtonProps) { + const baseStyles = + 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed'; + + const variants = { + primary: 'bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green', + secondary: + 'bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray', + danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500', + ghost: + 'bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white', + }; + + const sizes = { + sm: 'px-3 py-1.5 text-sm gap-1.5', + md: 'px-4 py-2 text-sm gap-2', + lg: 'px-6 py-3 text-base gap-2', + }; + + return ( + + ); +} diff --git a/frontend/src/components/CalendarView.tsx b/frontend/src/components/CalendarView.tsx new file mode 100644 index 000000000..a77471ccd --- /dev/null +++ b/frontend/src/components/CalendarView.tsx @@ -0,0 +1,272 @@ +import { useState, useMemo } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import type { Archive } from '../api/client'; +import { api } from '../api/client'; + +interface CalendarViewProps { + archives: Archive[]; + onArchiveClick?: (archive: Archive) => void; +} + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfMonth(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' +]; + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) { + const today = new Date(); + const [currentMonth, setCurrentMonth] = useState(today.getMonth()); + const [currentYear, setCurrentYear] = useState(today.getFullYear()); + const [selectedDate, setSelectedDate] = useState(null); + + // Group archives by date + const archivesByDate = useMemo(() => { + const map = new Map(); + archives.forEach(archive => { + const date = new Date(archive.completed_at || archive.created_at); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const existing = map.get(key) || []; + existing.push(archive); + map.set(key, existing); + }); + return map; + }, [archives]); + + const daysInMonth = getDaysInMonth(currentYear, currentMonth); + const firstDay = getFirstDayOfMonth(currentYear, currentMonth); + + const prevMonth = () => { + if (currentMonth === 0) { + setCurrentMonth(11); + setCurrentYear(currentYear - 1); + } else { + setCurrentMonth(currentMonth - 1); + } + }; + + const nextMonth = () => { + if (currentMonth === 11) { + setCurrentMonth(0); + setCurrentYear(currentYear + 1); + } else { + setCurrentMonth(currentMonth + 1); + } + }; + + const goToToday = () => { + setCurrentMonth(today.getMonth()); + setCurrentYear(today.getFullYear()); + }; + + // Build calendar grid + const calendarDays: (number | null)[] = []; + for (let i = 0; i < firstDay; i++) { + calendarDays.push(null); + } + for (let day = 1; day <= daysInMonth; day++) { + calendarDays.push(day); + } + + const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : []; + + return ( +
+ {/* Calendar */} +
+ {/* Header */} +
+ +
+

+ {MONTH_NAMES[currentMonth]} {currentYear} +

+ +
+ +
+ + {/* Day headers */} +
+ {DAY_NAMES.map(day => ( +
+ {day} +
+ ))} +
+ + {/* Calendar grid */} +
+ {calendarDays.map((day, index) => { + if (day === null) { + return
; + } + + const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const dayArchives = archivesByDate.get(dateKey) || []; + const hasArchives = dayArchives.length > 0; + const isToday = day === today.getDate() && currentMonth === today.getMonth() && currentYear === today.getFullYear(); + const isSelected = dateKey === selectedDate; + const successCount = dayArchives.filter(a => a.status === 'completed').length; + const failedCount = dayArchives.filter(a => a.status === 'failed').length; + + return ( + + ); + })} +
+ + {/* Monthly stats */} +
+
+
+
+ {archives.filter(a => { + const d = new Date(a.completed_at || a.created_at); + return d.getMonth() === currentMonth && d.getFullYear() === currentYear; + }).length} +
+
Prints this month
+
+
+
+ {archives.filter(a => { + const d = new Date(a.completed_at || a.created_at); + return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed'; + }).length} +
+
Successful
+
+
+
+ {archives.filter(a => { + const d = new Date(a.completed_at || a.created_at); + return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed'; + }).length} +
+
Failed
+
+
+
+
+ + {/* Selected day details */} +
+ {selectedDate ? ( + <> +

+ {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + })} +

+ {selectedArchives.length > 0 ? ( +
+ {selectedArchives.map(archive => ( + + ))} +
+ ) : ( +

No prints on this day

+ )} + + ) : ( +
+

Select a day to see prints

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx new file mode 100644 index 000000000..647cab68b --- /dev/null +++ b/frontend/src/components/Card.tsx @@ -0,0 +1,32 @@ +import type { ReactNode, MouseEvent } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + onClick?: (e: MouseEvent) => void; + onContextMenu?: (e: MouseEvent) => void; +} + +export function Card({ children, className = '', onClick, onContextMenu }: CardProps) { + return ( +
+ {children} +
+ ); +} + +export function CardHeader({ children, className = '' }: CardProps) { + return ( +
+ {children} +
+ ); +} + +export function CardContent({ children, className = '' }: CardProps) { + return
{children}
; +} diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 000000000..fe499b293 --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,82 @@ +import { useEffect } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; + +interface ConfirmModalProps { + title: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'warning' | 'default'; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmModal({ + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + variant = 'default', + onConfirm, + onCancel, +}: ConfirmModalProps) { + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onCancel]); + + const variantStyles = { + danger: { + icon: 'text-red-400', + button: 'bg-red-500 hover:bg-red-600', + }, + warning: { + icon: 'text-yellow-400', + button: 'bg-yellow-500 hover:bg-yellow-600', + }, + default: { + icon: 'text-bambu-green', + button: 'bg-bambu-green hover:bg-bambu-green-dark', + }, + }; + + const styles = variantStyles[variant]; + + return ( +
+ e.stopPropagation()}> + +
+
+ +
+
+

{title}

+

{message}

+
+
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx new file mode 100644 index 000000000..943831d1f --- /dev/null +++ b/frontend/src/components/ContextMenu.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from 'react'; + +export interface ContextMenuItem { + label: string; + icon?: React.ReactNode; + onClick: () => void; + danger?: boolean; + disabled?: boolean; + divider?: boolean; +} + +interface ContextMenuProps { + x: number; + y: number; + items: ContextMenuItem[]; + onClose: () => void; +} + +export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + const handleScroll = () => { + onClose(); + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + document.removeEventListener('scroll', handleScroll, true); + }; + }, [onClose]); + + // Adjust position to keep menu in viewport + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let adjustedX = x; + let adjustedY = y; + + if (x + rect.width > viewportWidth) { + adjustedX = viewportWidth - rect.width - 8; + } + if (y + rect.height > viewportHeight) { + adjustedY = viewportHeight - rect.height - 8; + } + + menuRef.current.style.left = `${adjustedX}px`; + menuRef.current.style.top = `${adjustedY}px`; + } + }, [x, y]); + + return ( +
+ {items.map((item, index) => { + if (item.divider) { + return
; + } + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx new file mode 100644 index 000000000..3cf38a658 --- /dev/null +++ b/frontend/src/components/Dashboard.tsx @@ -0,0 +1,325 @@ +import { useState, useEffect, type ReactNode } from 'react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + rectSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical, Eye, EyeOff, RotateCcw, Maximize2, Minimize2 } from 'lucide-react'; +import { Button } from './Button'; + +export interface DashboardWidget { + id: string; + title: string; + component: ReactNode; + defaultVisible?: boolean; + defaultSize?: 1 | 2 | 4; // 1 = quarter, 2 = half, 4 = full width (default) +} + +interface DashboardProps { + widgets: DashboardWidget[]; + storageKey: string; + columns?: number; +} + +interface LayoutState { + order: string[]; + hidden: string[]; + sizes: Record; +} + +function SortableWidget({ + id, + title, + children, + isHidden, + size, + onToggleVisibility, + onToggleSize, +}: { + id: string; + title: string; + children: ReactNode; + isHidden: boolean; + size: 1 | 2 | 4; + onToggleVisibility: () => void; + onToggleSize: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + if (isHidden) return null; + + return ( +
+ {/* Widget Header */} +
+
+ +

{title}

+
+
+ + +
+
+ {/* Widget Content */} +
{children}
+
+ ); +} + +export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) { + // Build default sizes from widget definitions + const getDefaultSizes = () => { + const sizes: Record = {}; + widgets.forEach((w) => { + sizes[w.id] = w.defaultSize || 4; + }); + return sizes; + }; + + const [layout, setLayout] = useState(() => { + // Load saved layout from localStorage + const saved = localStorage.getItem(storageKey); + if (saved) { + try { + const parsed = JSON.parse(saved); + // Ensure sizes exist (for backwards compatibility) + if (!parsed.sizes) { + parsed.sizes = getDefaultSizes(); + } + return parsed; + } catch { + // Invalid JSON, use default + } + } + // Default layout: all widgets visible in original order + return { + order: widgets.map((w) => w.id), + hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id), + sizes: getDefaultSizes(), + }; + }); + + const [showHiddenPanel, setShowHiddenPanel] = useState(false); + + // Save layout to localStorage whenever it changes + useEffect(() => { + localStorage.setItem(storageKey, JSON.stringify(layout)); + }, [layout, storageKey]); + + // Ensure all widget IDs are in the order array (for newly added widgets) + useEffect(() => { + const allIds = widgets.map((w) => w.id); + const missingIds = allIds.filter((id) => !layout.order.includes(id)); + if (missingIds.length > 0) { + setLayout((prev) => ({ + ...prev, + order: [...prev.order, ...missingIds], + })); + } + }, [widgets, layout.order]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setLayout((prev) => { + const oldIndex = prev.order.indexOf(active.id as string); + const newIndex = prev.order.indexOf(over.id as string); + return { + ...prev, + order: arrayMove(prev.order, oldIndex, newIndex), + }; + }); + } + }; + + const toggleVisibility = (id: string) => { + setLayout((prev) => ({ + ...prev, + hidden: prev.hidden.includes(id) + ? prev.hidden.filter((h) => h !== id) + : [...prev.hidden, id], + })); + }; + + const toggleSize = (id: string) => { + setLayout((prev) => { + const currentSize = prev.sizes[id] || 4; + // Cycle: 1 → 2 → 4 → 1 + const nextSize = currentSize === 1 ? 2 : currentSize === 2 ? 4 : 1; + return { + ...prev, + sizes: { + ...prev.sizes, + [id]: nextSize as 1 | 2 | 4, + }, + }; + }); + }; + + const resetLayout = () => { + const defaultLayout = { + order: widgets.map((w) => w.id), + hidden: widgets.filter((w) => w.defaultVisible === false).map((w) => w.id), + sizes: getDefaultSizes(), + }; + setLayout(defaultLayout); + }; + + // Get ordered widgets + const orderedWidgets = layout.order + .map((id) => widgets.find((w) => w.id === id)) + .filter(Boolean) as DashboardWidget[]; + + const visibleWidgets = orderedWidgets.filter((w) => !layout.hidden.includes(w.id)); + const hiddenWidgets = orderedWidgets.filter((w) => layout.hidden.includes(w.id)); + + return ( +
+ {/* Dashboard Controls */} +
+ {hiddenWidgets.length > 0 && ( + + )} + +
+ + {/* Hidden Widgets Panel */} + {showHiddenPanel && hiddenWidgets.length > 0 && ( +
+

Hidden widgets (click to show):

+
+ {hiddenWidgets.map((widget) => ( + + ))} +
+
+ )} + + {/* Draggable Widgets Grid */} + + w.id)} strategy={rectSortingStrategy}> +
+ {visibleWidgets.map((widget) => ( + toggleVisibility(widget.id)} + onToggleSize={() => toggleSize(widget.id)} + > + {widget.component} + + ))} +
+
+
+ + {visibleWidgets.length === 0 && ( +
+

All widgets are hidden.

+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx new file mode 100644 index 000000000..77205adb5 --- /dev/null +++ b/frontend/src/components/EditArchiveModal.tsx @@ -0,0 +1,358 @@ +import { useState, useEffect, useRef } from 'react'; +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { X, Save, Tag, Camera, Trash2, Loader2, Plus } from 'lucide-react'; +import { api } from '../api/client'; +import type { Archive } from '../api/client'; +import { Button } from './Button'; + +const FAILURE_REASONS = [ + 'Adhesion failure', + 'Spaghetti / Detached', + 'Layer shift', + 'Clogged nozzle', + 'Filament runout', + 'Warping', + 'Stringing', + 'Under-extrusion', + 'Power failure', + 'User cancelled', + 'Other', +]; + +interface EditArchiveModalProps { + archive: Archive; + onClose: () => void; + existingTags?: string[]; +} + +export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) { + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + const queryClient = useQueryClient(); + const [printName, setPrintName] = useState(archive.print_name || ''); + const [printerId, setPrinterId] = useState(archive.printer_id); + const [notes, setNotes] = useState(archive.notes || ''); + const [tags, setTags] = useState(archive.tags || ''); + const [failureReason, setFailureReason] = useState(archive.failure_reason || ''); + const [photos, setPhotos] = useState(archive.photos || []); + const [uploadingPhoto, setUploadingPhoto] = useState(false); + const [showTagSuggestions, setShowTagSuggestions] = useState(false); + const tagInputRef = useRef(null); + const photoInputRef = useRef(null); + const blurTimeoutRef = useRef(null); + + const { data: printers } = useQuery({ + queryKey: ['printers'], + queryFn: api.getPrinters, + }); + + // Get all archives to extract existing tags if not provided + const { data: archives } = useQuery({ + queryKey: ['archives'], + queryFn: () => api.getArchives(undefined, 1000, 0), + enabled: existingTags.length === 0, + }); + + // Extract unique tags from all archives + const allTags = existingTags.length > 0 + ? existingTags + : [...new Set( + archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || [] + )].sort(); + + // Get current tags as array + const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean); + + // Filter suggestions based on what's not already added + const tagSuggestions = allTags.filter(t => !currentTags.includes(t)); + + // Add a tag + const addTag = (tag: string) => { + if (!currentTags.includes(tag)) { + const newTags = [...currentTags, tag].join(', '); + setTags(newTags); + } + // Clear any pending blur timeout to prevent hiding suggestions + if (blurTimeoutRef.current !== null) { + clearTimeout(blurTimeoutRef.current); + } + tagInputRef.current?.focus(); + }; + + // Remove a tag + const removeTag = (tagToRemove: string) => { + const newTags = currentTags.filter(t => t !== tagToRemove).join(', '); + setTags(newTags); + }; + + const updateMutation = useMutation({ + mutationFn: (data: Parameters[1]) => + api.updateArchive(archive.id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['archives'] }); + onClose(); + }, + }); + + const handlePhotoUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploadingPhoto(true); + try { + const result = await api.uploadArchivePhoto(archive.id, file); + setPhotos(result.photos); + queryClient.invalidateQueries({ queryKey: ['archives'] }); + } catch (error) { + console.error('Failed to upload photo:', error); + } finally { + setUploadingPhoto(false); + if (photoInputRef.current) { + photoInputRef.current.value = ''; + } + } + }; + + const handlePhotoDelete = async (filename: string) => { + try { + const result = await api.deleteArchivePhoto(archive.id, filename); + setPhotos(result.photos || []); + queryClient.invalidateQueries({ queryKey: ['archives'] }); + } catch (error) { + console.error('Failed to delete photo:', error); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateMutation.mutate({ + print_name: printName || undefined, + printer_id: printerId, + notes: notes || undefined, + tags: tags || undefined, + failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined, + }); + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Edit Archive

+ +
+ + {/* Form */} +
+ {/* Print Name */} +
+ + setPrintName(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + placeholder="Print name" + /> +
+ + {/* Printer */} +
+ + +
+ + {/* Notes */} +
+ +