diff --git a/README.md b/README.md index 72e6035cb..241373fce 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,39 @@ This is a first beta version and needs to be tested thoroughly. - **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 +- **Smart Plug Integration** - Control Tasmota-based smart plugs with automation: + - Auto power-on when print starts + - Auto power-off when print completes + - Time-based delay (1-60 minutes) + - Temperature-based delay (waits for nozzle to cool down) - **Print Statistics Dashboard** - Customizable dashboard with drag-and-drop widgets - Print success rates - Filament usage trends - Print activity calendar - Cost tracking + - Time accuracy tracking +- **Print Time Accuracy** - Compare estimated vs actual print times + - Color-coded badges (green=accurate, blue=faster, orange=slower) + - Per-printer accuracy statistics + - Dashboard widget showing average accuracy +- **Duplicate Detection** - Automatically detect when models have been printed before + - SHA256 content hash for exact matching + - Purple badge indicator on archive cards + - "Duplicates" collection filter to find all duplicates + - View duplicate history when viewing archive details +- **HMS Error Monitoring** - Real-time Health Management System status + - Always-visible status indicator on printer cards + - Green "OK" when healthy, orange/red when errors detected + - Severity-based coloring (fatal, serious, common, info) +- **MQTT Debug Logging** - Built-in debugging tool for printer communication + - Start/stop logging with one click + - Real-time message capture with auto-refresh + - View incoming and outgoing MQTT messages + - Expandable JSON payloads for detailed inspection - **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 +- **Project Page Editor** - View and edit embedded MakerWorld project pages with images, descriptions, and designer info - **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 @@ -313,6 +338,61 @@ Prints are automatically archived when they complete. You can also: - Re-print any archived 3MF to a connected printer - Export archives for backup +### Project Page + +3MF files downloaded from MakerWorld contain embedded project pages with model information. To view: +1. Right-click any archive in the Archives page +2. Select "Project Page" from the context menu +3. View title, description, designer info, license, and images +4. Click "Edit" to modify the project page metadata +5. Changes are saved directly to the 3MF file + +### Smart Plug Integration + +Bambusy supports Tasmota-based smart plugs for automated power control. This is useful for: +- Automatically turning on your printer when a print starts +- Safely turning off the printer after it cools down +- Energy savings by powering off idle printers + +#### Supported Devices + +Any smart plug running [Tasmota](https://tasmota.github.io/docs/) firmware with HTTP API enabled. Popular compatible devices include: +- Sonoff S31 / S26 +- Gosund / Teckin / Treatlife smart plugs +- Any ESP8266/ESP32-based plug with Tasmota + +#### Setting Up a Smart Plug + +1. Go to **Settings** > **Smart Plugs** +2. Click **Add Plug** +3. Enter the plug's IP address and click **Test** to verify connection +4. Give it a name (auto-filled from device if available) +5. Optionally add username/password if your Tasmota requires authentication +6. Link it to a printer for automation +7. Click **Add** + +#### Automation Options + +Once linked to a printer, you can configure: + +| Setting | Description | +|---------|-------------| +| **Enabled** | Master toggle for all automation | +| **Auto On** | Turn on plug when a print starts | +| **Auto Off** | Turn off plug when print completes | +| **Delay Mode** | Choose how to delay the power-off | + +**Delay Modes:** +- **Time-based**: Wait a fixed number of minutes (1-60) after print completes +- **Temperature-based**: Wait until nozzle temperature drops below threshold (default 70°C) + +#### Manual Control + +Each plug card shows: +- Current status (ON/OFF/Offline) +- On/Off buttons for manual control +- Expandable settings panel + ## Tech Stack - **Backend**: Python / FastAPI @@ -402,6 +482,20 @@ uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --log-level debug sudo journalctl -u bambusy -f ``` +### Smart plug not responding + +1. **Check the IP address** - Make sure the plug is on the same network and the IP hasn't changed +2. **Test via browser** - Visit `http:///cm?cmnd=Power` to test directly +3. **Check Tasmota web interface** - Access `http://` to verify Tasmota is running +4. **Authentication** - If Tasmota has a password set, configure it in the plug settings +5. **Firewall** - Ensure port 80 is accessible between Bambusy server and the plug + +### Auto power-off not working + +1. **Check plug is linked** - The plug must be linked to a printer for automation +2. **Verify automation is enabled** - Check the Enabled, Auto On, and Auto Off toggles +3. **Temperature mode issues** - If using temperature mode, ensure the printer is still connected so Bambusy can read the nozzle temperature + ## Known Issues / Roadmap ### Beta Limitations @@ -416,6 +510,11 @@ sudo journalctl -u bambusy -f - [ ] Timelapse video integration - [ ] Mobile-responsive improvements - [ ] Printer groups/organization +- [x] Smart plug integration (Tasmota) +- [x] Print time accuracy tracking +- [x] Duplicate detection +- [x] HMS error monitoring +- [x] MQTT debug logging ## License diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index a264767bb..6a3a714d5 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -17,6 +17,78 @@ from backend.app.services.archive import ArchiveService router = APIRouter(prefix="/archives", tags=["archives"]) +def compute_time_accuracy(archive: PrintArchive) -> dict: + """Compute actual print time and accuracy for an archive. + + Returns dict with actual_time_seconds and time_accuracy. + time_accuracy = (estimated / actual) * 100 + - 100% = perfect estimate + - >100% = print was faster than estimated + - <100% = print took longer than estimated + """ + result = {"actual_time_seconds": None, "time_accuracy": None} + + if archive.started_at and archive.completed_at and archive.status == "completed": + actual_seconds = int((archive.completed_at - archive.started_at).total_seconds()) + if actual_seconds > 0: + result["actual_time_seconds"] = actual_seconds + + if archive.print_time_seconds and archive.print_time_seconds > 0: + # Calculate accuracy as percentage + accuracy = (archive.print_time_seconds / actual_seconds) * 100 + result["time_accuracy"] = round(accuracy, 1) + + return result + + +def archive_to_response( + archive: PrintArchive, + duplicates: list[dict] | None = None, + duplicate_count: int = 0, +) -> dict: + """Convert archive model to response dict with computed fields.""" + data = { + "id": archive.id, + "printer_id": archive.printer_id, + "filename": archive.filename, + "file_path": archive.file_path, + "file_size": archive.file_size, + "content_hash": archive.content_hash, + "thumbnail_path": archive.thumbnail_path, + "timelapse_path": archive.timelapse_path, + "duplicates": duplicates, + "duplicate_count": duplicate_count if duplicates is None else len(duplicates), + "print_name": archive.print_name, + "print_time_seconds": archive.print_time_seconds, + "filament_used_grams": archive.filament_used_grams, + "filament_type": archive.filament_type, + "filament_color": archive.filament_color, + "layer_height": archive.layer_height, + "nozzle_diameter": archive.nozzle_diameter, + "bed_temperature": archive.bed_temperature, + "nozzle_temperature": archive.nozzle_temperature, + "status": archive.status, + "started_at": archive.started_at, + "completed_at": archive.completed_at, + "extra_data": archive.extra_data, + "makerworld_url": archive.makerworld_url, + "designer": archive.designer, + "is_favorite": archive.is_favorite, + "tags": archive.tags, + "notes": archive.notes, + "cost": archive.cost, + "photos": archive.photos, + "failure_reason": archive.failure_reason, + "created_at": archive.created_at, + } + + # Add computed time accuracy fields + accuracy_data = compute_time_accuracy(archive) + data.update(accuracy_data) + + return data + + @router.get("/", response_model=list[ArchiveResponse]) async def list_archives( printer_id: int | None = None, @@ -26,12 +98,22 @@ async def list_archives( ): """List archived prints.""" service = ArchiveService(db) - return await service.list_archives( + archives = await service.list_archives( printer_id=printer_id, limit=limit, offset=offset, ) + # Get set of hashes that have duplicates (efficient single query) + duplicate_hashes = await service.get_duplicate_hashes() + + # Mark archives that have duplicates + result = [] + for a in archives: + has_duplicate = a.content_hash in duplicate_hashes if a.content_hash else False + result.append(archive_to_response(a, duplicate_count=1 if has_duplicate else 0)) + return result + @router.get("/stats", response_model=ArchiveStats) async def get_archive_stats(db: AsyncSession = Depends(get_db)): @@ -86,6 +168,42 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): ) prints_by_printer = {str(k): v for k, v in printer_result.all()} + # Time accuracy statistics + # Get all completed archives with both estimated and actual times + accuracy_result = await db.execute( + select(PrintArchive) + .where(PrintArchive.status == "completed") + .where(PrintArchive.print_time_seconds.isnot(None)) + .where(PrintArchive.started_at.isnot(None)) + .where(PrintArchive.completed_at.isnot(None)) + ) + archives_with_times = list(accuracy_result.scalars().all()) + + average_accuracy = None + accuracy_by_printer: dict[str, float] = {} + + if archives_with_times: + accuracies = [] + printer_accuracies: dict[str, list[float]] = {} + + for archive in archives_with_times: + acc_data = compute_time_accuracy(archive) + if acc_data["time_accuracy"] is not None: + accuracies.append(acc_data["time_accuracy"]) + + # Group by printer + printer_key = str(archive.printer_id) if archive.printer_id else "unknown" + if printer_key not in printer_accuracies: + printer_accuracies[printer_key] = [] + printer_accuracies[printer_key].append(acc_data["time_accuracy"]) + + if accuracies: + average_accuracy = round(sum(accuracies) / len(accuracies), 1) + + # Calculate per-printer averages + for printer_key, accs in printer_accuracies.items(): + accuracy_by_printer[printer_key] = round(sum(accs) / len(accs), 1) + return ArchiveStats( total_prints=total_prints, successful_prints=successful_prints, @@ -95,6 +213,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)): total_cost=round(total_cost, 2), prints_by_filament_type=prints_by_filament, prints_by_printer=prints_by_printer, + average_time_accuracy=average_accuracy, + time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None, ) @@ -105,7 +225,16 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)): archive = await service.get_archive(archive_id) if not archive: raise HTTPException(404, "Archive not found") - return archive + + # Find duplicates + makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None + duplicates = await service.find_duplicates( + archive_id=archive.id, + content_hash=archive.content_hash, + print_name=archive.print_name, + makerworld_model_id=makerworld_id, + ) + return archive_to_response(archive, duplicates) @router.patch("/{archive_id}", response_model=ArchiveResponse) @@ -242,6 +371,51 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)): return {"updated": updated, "errors": errors} +@router.get("/{archive_id}/duplicates") +async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get duplicates for a specific archive.""" + service = ArchiveService(db) + archive = await service.get_archive(archive_id) + if not archive: + raise HTTPException(404, "Archive not found") + + makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None + duplicates = await service.find_duplicates( + archive_id=archive.id, + content_hash=archive.content_hash, + print_name=archive.print_name, + makerworld_model_id=makerworld_id, + ) + return {"duplicates": duplicates, "count": len(duplicates)} + + +@router.post("/backfill-hashes") +async def backfill_content_hashes(db: AsyncSession = Depends(get_db)): + """Compute and store content hashes for all archives missing them.""" + result = await db.execute( + select(PrintArchive).where(PrintArchive.content_hash.is_(None)) + ) + 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 + + archive.content_hash = ArchiveService.compute_file_hash(file_path) + 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.""" @@ -888,3 +1062,89 @@ async def reprint_archive( "archive_id": archive_id, "filename": archive.filename, } + + +# ============================================================================= +# Project Page API +# ============================================================================= + +@router.get("/{archive_id}/project-page") +async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get the project page data from the 3MF file.""" + from backend.app.services.archive import ProjectPageParser + from backend.app.schemas.archive import ProjectPageResponse + + 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, "Archive file not found") + + parser = ProjectPageParser(file_path) + data = parser.parse(archive_id) + + return ProjectPageResponse(**data) + + +@router.patch("/{archive_id}/project-page") +async def update_project_page( + archive_id: int, + update_data: dict, + db: AsyncSession = Depends(get_db), +): + """Update project page metadata in the 3MF file.""" + from backend.app.services.archive import ProjectPageParser + + 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, "Archive file not found") + + parser = ProjectPageParser(file_path) + success = parser.update_metadata(update_data) + + if not success: + raise HTTPException(500, "Failed to update project page") + + # Return updated data + data = parser.parse(archive_id) + return data + + +@router.get("/{archive_id}/project-image/{image_path:path}") +async def get_project_image( + archive_id: int, + image_path: str, + db: AsyncSession = Depends(get_db), +): + """Get an image from the 3MF project page.""" + from backend.app.services.archive import ProjectPageParser + + 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, "Archive file not found") + + parser = ProjectPageParser(file_path) + result = parser.get_image(image_path) + + if not result: + raise HTTPException(404, "Image not found in 3MF file") + + image_data, content_type = result + return Response( + content=image_data, + media_type=content_type, + headers={"Cache-Control": "max-age=3600"}, + ) diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index bc67c8ce9..e8f891284 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -18,6 +18,7 @@ from backend.app.schemas.printer import ( PrinterUpdate, PrinterResponse, PrinterStatus, + HMSErrorResponse, ) from backend.app.services.printer_manager import printer_manager from backend.app.services.bambu_ftp import ( @@ -138,6 +139,12 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) if state.state == "RUNNING" and state.gcode_file: cover_url = f"/api/v1/printers/{printer_id}/cover" + # Convert HMS errors to response format + hms_errors = [ + HMSErrorResponse(code=e.code, module=e.module, severity=e.severity) + for e in (state.hms_errors or []) + ] + return PrinterStatus( id=printer_id, name=printer.name, @@ -152,6 +159,7 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) total_layers=state.total_layers, temperatures=state.temperatures, cover_url=cover_url, + hms_errors=hms_errors, ) @@ -410,3 +418,72 @@ async def get_printer_storage( storage_info = await get_storage_info_async(printer.ip_address, printer.access_code) return storage_info or {"used_bytes": None, "free_bytes": None} + + +# ============================================ +# MQTT Debug Logging Endpoints +# ============================================ + +@router.post("/{printer_id}/logging/enable") +async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)): + """Enable MQTT message logging for 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 = printer_manager.enable_logging(printer_id, True) + if not success: + raise HTTPException(400, "Printer not connected") + + return {"logging_enabled": True} + + +@router.post("/{printer_id}/logging/disable") +async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)): + """Disable MQTT message logging for 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 = printer_manager.enable_logging(printer_id, False) + if not success: + raise HTTPException(400, "Printer not connected") + + return {"logging_enabled": False} + + +@router.get("/{printer_id}/logging") +async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get MQTT message logs for 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") + + logs = printer_manager.get_logs(printer_id) + return { + "logging_enabled": printer_manager.is_logging_enabled(printer_id), + "logs": [ + { + "timestamp": log.timestamp, + "topic": log.topic, + "direction": log.direction, + "payload": log.payload, + } + for log in logs + ], + } + + +@router.delete("/{printer_id}/logging") +async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)): + """Clear MQTT message logs for 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.clear_logs(printer_id) + return {"status": "cleared"} diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 1d47ae5c9..ae720d8c3 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -1,4 +1,3 @@ -import json from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py new file mode 100644 index 000000000..45fdf053a --- /dev/null +++ b/backend/app/api/routes/smart_plugs.py @@ -0,0 +1,211 @@ +"""API routes for smart plug management.""" + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.core.database import get_db +from backend.app.models.smart_plug import SmartPlug +from backend.app.models.printer import Printer +from backend.app.schemas.smart_plug import ( + SmartPlugCreate, + SmartPlugUpdate, + SmartPlugResponse, + SmartPlugControl, + SmartPlugStatus, + SmartPlugTestConnection, +) +from backend.app.services.tasmota import tasmota_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"]) + + +@router.get("/", response_model=list[SmartPlugResponse]) +async def list_smart_plugs(db: AsyncSession = Depends(get_db)): + """List all smart plugs.""" + result = await db.execute(select(SmartPlug).order_by(SmartPlug.name)) + return list(result.scalars().all()) + + +@router.post("/", response_model=SmartPlugResponse) +async def create_smart_plug( + data: SmartPlugCreate, + db: AsyncSession = Depends(get_db), +): + """Create a new smart plug.""" + # Validate printer_id if provided + if data.printer_id: + result = await db.execute( + select(Printer).where(Printer.id == data.printer_id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(400, "Printer not found") + + # Check if printer already has a plug assigned + result = await db.execute( + select(SmartPlug).where(SmartPlug.printer_id == data.printer_id) + ) + if result.scalar_one_or_none(): + raise HTTPException(400, "This printer already has a smart plug assigned") + + plug = SmartPlug(**data.model_dump()) + db.add(plug) + await db.commit() + await db.refresh(plug) + + logger.info(f"Created smart plug '{plug.name}' at {plug.ip_address}") + return plug + + +@router.get("/{plug_id}", response_model=SmartPlugResponse) +async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)): + """Get a specific smart plug.""" + result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) + plug = result.scalar_one_or_none() + if not plug: + raise HTTPException(404, "Smart plug not found") + return plug + + +@router.patch("/{plug_id}", response_model=SmartPlugResponse) +async def update_smart_plug( + plug_id: int, + data: SmartPlugUpdate, + db: AsyncSession = Depends(get_db), +): + """Update a smart plug.""" + result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) + plug = result.scalar_one_or_none() + if not plug: + raise HTTPException(404, "Smart plug not found") + + update_data = data.model_dump(exclude_unset=True) + + # Validate new printer_id if being changed + if "printer_id" in update_data and update_data["printer_id"]: + new_printer_id = update_data["printer_id"] + + # Check printer exists + result = await db.execute( + select(Printer).where(Printer.id == new_printer_id) + ) + if not result.scalar_one_or_none(): + raise HTTPException(400, "Printer not found") + + # Check if that printer already has a different plug assigned + result = await db.execute( + select(SmartPlug).where( + SmartPlug.printer_id == new_printer_id, + SmartPlug.id != plug_id, + ) + ) + if result.scalar_one_or_none(): + raise HTTPException(400, "This printer already has a smart plug assigned") + + for field, value in update_data.items(): + setattr(plug, field, value) + + await db.commit() + await db.refresh(plug) + + logger.info(f"Updated smart plug '{plug.name}'") + return plug + + +@router.delete("/{plug_id}") +async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)): + """Delete a smart plug.""" + result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) + plug = result.scalar_one_or_none() + if not plug: + raise HTTPException(404, "Smart plug not found") + + plug_name = plug.name + await db.delete(plug) + await db.commit() + + logger.info(f"Deleted smart plug '{plug_name}'") + return {"message": "Smart plug deleted"} + + +@router.post("/{plug_id}/control") +async def control_smart_plug( + plug_id: int, + control: SmartPlugControl, + db: AsyncSession = Depends(get_db), +): + """Manual control: on/off/toggle.""" + result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) + plug = result.scalar_one_or_none() + if not plug: + raise HTTPException(404, "Smart plug not found") + + if control.action == "on": + success = await tasmota_service.turn_on(plug) + expected_state = "ON" + elif control.action == "off": + success = await tasmota_service.turn_off(plug) + expected_state = "OFF" + elif control.action == "toggle": + success = await tasmota_service.toggle(plug) + expected_state = None # Unknown after toggle + else: + raise HTTPException(400, f"Invalid action: {control.action}") + + if not success: + raise HTTPException(503, "Failed to communicate with device") + + # Update last state + if expected_state: + plug.last_state = expected_state + plug.last_checked = datetime.utcnow() + await db.commit() + + return {"success": True, "action": control.action} + + +@router.get("/{plug_id}/status", response_model=SmartPlugStatus) +async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): + """Get current plug status from device.""" + result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) + plug = result.scalar_one_or_none() + if not plug: + raise HTTPException(404, "Smart plug not found") + + status = await tasmota_service.get_status(plug) + + # Update last state in database + if status["reachable"]: + plug.last_state = status["state"] + plug.last_checked = datetime.utcnow() + await db.commit() + + return SmartPlugStatus( + state=status["state"], + reachable=status["reachable"], + device_name=status.get("device_name"), + ) + + +@router.post("/test-connection") +async def test_connection(data: SmartPlugTestConnection): + """Test connection to a Tasmota device.""" + result = await tasmota_service.test_connection( + data.ip_address, + data.username, + data.password, + ) + + if not result["success"]: + raise HTTPException(503, result.get("error", "Failed to connect to device")) + + return { + "success": True, + "state": result["state"], + "device_name": result.get("device_name"), + } diff --git a/backend/app/core/database.py b/backend/app/core/database.py index ef056b9a6..80fe47a32 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -55,3 +55,12 @@ async def run_migrations(conn): except Exception: # Column already exists pass + + # Migration: Add content_hash column to print_archives for duplicate detection + try: + await conn.execute(text( + "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)" + )) + except Exception: + # Column already exists + pass diff --git a/backend/app/main.py b/backend/app/main.py index 1de8b6386..30fe4cf2d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,7 +10,7 @@ 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 printers, archives, websocket, filaments, cloud, smart_plugs from backend.app.api.routes import settings as settings_routes from backend.app.services.printer_manager import ( printer_manager, @@ -20,6 +20,7 @@ from backend.app.services.printer_manager import ( 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 +from backend.app.services.smart_plug_manager import smart_plug_manager # Track active prints: {(printer_id, filename): archive_id} @@ -177,6 +178,14 @@ async def on_print_start(printer_id: int, data: dict): if temp_path and temp_path.exists(): temp_path.unlink() + # Smart plug automation: turn on plug when print starts + try: + async with async_session() as db: + await smart_plug_manager.on_print_start(printer_id, db) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}") + async def on_print_complete(printer_id: int, data: dict): """Handle print completion - update the archive status.""" @@ -251,6 +260,15 @@ async def on_print_complete(printer_id: int, data: dict): "status": status, }) + # Smart plug automation: schedule turn off when print completes + try: + async with async_session() as db: + status = data.get("status", "completed") + await smart_plug_manager.on_print_complete(printer_id, status, db) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Smart plug on_print_complete failed: {e}") + @asynccontextmanager async def lifespan(app: FastAPI): @@ -277,7 +295,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title=app_settings.app_name, description="Archive and manage Bambu Lab 3MF files", - version="0.1.1", + version="0.1.2", lifespan=lifespan, ) @@ -287,6 +305,7 @@ 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(smart_plugs.router, prefix=app_settings.api_prefix) app.include_router(websocket.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e69de29bb..a95f9c28d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -0,0 +1,7 @@ +from backend.app.models.printer import Printer +from backend.app.models.archive import PrintArchive +from backend.app.models.filament import Filament +from backend.app.models.settings import Settings +from backend.app.models.smart_plug import SmartPlug + +__all__ = ["Printer", "PrintArchive", "Filament", "Settings", "SmartPlug"] diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py index 7c0719386..3e601ba9f 100644 --- a/backend/app/models/archive.py +++ b/backend/app/models/archive.py @@ -15,6 +15,7 @@ class PrintArchive(Base): filename: Mapped[str] = mapped_column(String(255)) file_path: Mapped[str] = mapped_column(String(500)) file_size: Mapped[int] = mapped_column(Integer) + content_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 hash for duplicate detection thumbnail_path: Mapped[str | None] = mapped_column(String(500)) timelapse_path: Mapped[str | None] = mapped_column(String(500)) diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index f7b08cd0b..85c95bdc1 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -27,6 +27,10 @@ class Printer(Base): archives: Mapped[list["PrintArchive"]] = relationship( back_populates="printer", cascade="all, delete-orphan" ) + smart_plug: Mapped["SmartPlug | None"] = relationship( + back_populates="printer", uselist=False + ) from backend.app.models.archive import PrintArchive # noqa: E402 +from backend.app.models.smart_plug import SmartPlug # noqa: E402 diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py new file mode 100644 index 000000000..c1c43fcd7 --- /dev/null +++ b/backend/app/models/smart_plug.py @@ -0,0 +1,50 @@ +from datetime import datetime +from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class SmartPlug(Base): + """Tasmota smart plug for printer power control.""" + + __tablename__ = "smart_plugs" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100)) + ip_address: Mapped[str] = mapped_column(String(45)) # IPv4/IPv6 + + # Link to printer (1:1) + printer_id: Mapped[int | None] = mapped_column( + ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True + ) + + # Automation settings + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + auto_on: Mapped[bool] = mapped_column(Boolean, default=True) # Turn on at print start + auto_off: Mapped[bool] = mapped_column(Boolean, default=True) # Turn off at print complete/fail + + # Turn-off delay mode: "time" or "temperature" + off_delay_mode: Mapped[str] = mapped_column(String(20), default="time") + off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5) # For time mode + off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70) # For temp mode (°C) + + # Optional auth (some Tasmota configs require it) + username: Mapped[str | None] = mapped_column(String(50), nullable=True) + password: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Status tracking + last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF" + last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # Timestamps + 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() + ) + + # Relationship + printer: Mapped["Printer"] = relationship(back_populates="smart_plug") + + +from backend.app.models.printer import Printer # noqa: E402 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e69de29bb..73b0c7445 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,43 @@ +from backend.app.schemas.printer import ( + PrinterBase, + PrinterCreate, + PrinterUpdate, + PrinterResponse, + PrinterStatus, +) +from backend.app.schemas.archive import ( + ArchiveBase, + ArchiveUpdate, + ArchiveResponse, + ProjectPageResponse, + ProjectPageImage, +) +from backend.app.schemas.smart_plug import ( + SmartPlugBase, + SmartPlugCreate, + SmartPlugUpdate, + SmartPlugResponse, + SmartPlugControl, + SmartPlugStatus, + SmartPlugTestConnection, +) + +__all__ = [ + "PrinterBase", + "PrinterCreate", + "PrinterUpdate", + "PrinterResponse", + "PrinterStatus", + "ArchiveBase", + "ArchiveUpdate", + "ArchiveResponse", + "ProjectPageResponse", + "ProjectPageImage", + "SmartPlugBase", + "SmartPlugCreate", + "SmartPlugUpdate", + "SmartPlugResponse", + "SmartPlugControl", + "SmartPlugStatus", + "SmartPlugTestConnection", +] diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py index e78608d82..70f29ca24 100644 --- a/backend/app/schemas/archive.py +++ b/backend/app/schemas/archive.py @@ -15,17 +15,32 @@ class ArchiveUpdate(ArchiveBase): printer_id: int | None = None +class ArchiveDuplicate(BaseModel): + """Reference to a duplicate archive.""" + id: int + print_name: str | None + created_at: datetime + match_type: str # "exact" (hash match) or "similar" (name match) + + class ArchiveResponse(BaseModel): id: int printer_id: int | None filename: str file_path: str file_size: int + content_hash: str | None thumbnail_path: str | None timelapse_path: str | None + # Duplicate detection + duplicates: list[ArchiveDuplicate] | None = None + duplicate_count: int = 0 # Quick count for list views + print_name: str | None - print_time_seconds: int | None + print_time_seconds: int | None # Estimated time from slicer + actual_time_seconds: int | None = None # Computed from started_at/completed_at + time_accuracy: float | None = None # Percentage: 100 = perfect, >100 = faster than estimated filament_used_grams: float | None filament_type: str | None filament_color: str | None @@ -65,3 +80,55 @@ class ArchiveStats(BaseModel): total_cost: float prints_by_filament_type: dict prints_by_printer: dict + # Time accuracy stats + average_time_accuracy: float | None = None # Average across all prints with data + time_accuracy_by_printer: dict | None = None # Per-printer accuracy + + +class ProjectPageImage(BaseModel): + """Image embedded in 3MF project page.""" + name: str + path: str # Path within 3MF + url: str # API URL to fetch image + + +class ProjectPageResponse(BaseModel): + """Project page data extracted from 3MF file.""" + # Model info + title: str | None = None + description: str | None = None # HTML content + designer: str | None = None + designer_user_id: str | None = None + license: str | None = None + copyright: str | None = None + creation_date: str | None = None + modification_date: str | None = None + origin: str | None = None # "original" or "remix" + + # Profile info + profile_title: str | None = None + profile_description: str | None = None + profile_cover: str | None = None + profile_user_id: str | None = None + profile_user_name: str | None = None + + # MakerWorld info + design_model_id: str | None = None + design_profile_id: str | None = None + design_region: str | None = None + + # Images + model_pictures: list[ProjectPageImage] = [] + profile_pictures: list[ProjectPageImage] = [] + thumbnails: list[ProjectPageImage] = [] + + +class ProjectPageUpdate(BaseModel): + """Update project page data in 3MF file.""" + title: str | None = None + description: str | None = None + designer: str | None = None + license: str | None = None + copyright: str | None = None + profile_title: str | None = None + profile_description: str | None = None diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index d572f13f2..5c545cc71 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -34,6 +34,12 @@ class PrinterResponse(PrinterBase): from_attributes = True +class HMSErrorResponse(BaseModel): + code: str + module: int + severity: int # 1=fatal, 2=serious, 3=common, 4=info + + class PrinterStatus(BaseModel): id: int name: str @@ -48,3 +54,4 @@ class PrinterStatus(BaseModel): total_layers: int | None = None temperatures: dict | None = None cover_url: str | None = None + hms_errors: list[HMSErrorResponse] = [] diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py new file mode 100644 index 000000000..97ded1acb --- /dev/null +++ b/backend/app/schemas/smart_plug.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Literal +from pydantic import BaseModel, Field + + +class SmartPlugBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") + printer_id: int | None = None + enabled: bool = True + auto_on: bool = True + auto_off: bool = True + off_delay_mode: Literal["time", "temperature"] = "time" + off_delay_minutes: int = Field(default=5, ge=0, le=60) + off_temp_threshold: int = Field(default=70, ge=30, le=150) + username: str | None = None + password: str | None = None + + +class SmartPlugCreate(SmartPlugBase): + pass + + +class SmartPlugUpdate(BaseModel): + name: str | None = None + ip_address: str | None = None + printer_id: int | None = None + enabled: bool | None = None + auto_on: bool | None = None + auto_off: bool | None = None + off_delay_mode: Literal["time", "temperature"] | None = None + off_delay_minutes: int | None = Field(default=None, ge=0, le=60) + off_temp_threshold: int | None = Field(default=None, ge=30, le=150) + username: str | None = None + password: str | None = None + + +class SmartPlugResponse(SmartPlugBase): + id: int + last_state: str | None = None + last_checked: datetime | None = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SmartPlugControl(BaseModel): + action: Literal["on", "off", "toggle"] + + +class SmartPlugStatus(BaseModel): + state: str | None = None # "ON", "OFF", or None if unreachable + reachable: bool = True + device_name: str | None = None + + +class SmartPlugTestConnection(BaseModel): + ip_address: str = Field(..., pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") + username: str | None = None + password: str | None = None diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py index 782188f0a..ae15dbc67 100644 --- a/backend/app/services/archive.py +++ b/backend/app/services/archive.py @@ -1,3 +1,4 @@ +import hashlib import json import zipfile import shutil @@ -6,7 +7,7 @@ from pathlib import Path from xml.etree import ElementTree as ET from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, and_, or_ from backend.app.core.config import settings from backend.app.models.archive import PrintArchive @@ -264,12 +265,306 @@ class ThreeMFParser: break +class ProjectPageParser: + """Parser for extracting project page data from Bambu Lab 3MF files.""" + + def __init__(self, file_path: Path): + self.file_path = file_path + + def parse(self, archive_id: int) -> dict: + """Extract project page metadata and images from 3MF file.""" + import html + import re + + result = { + "title": None, + "description": None, + "designer": None, + "designer_user_id": None, + "license": None, + "copyright": None, + "creation_date": None, + "modification_date": None, + "origin": None, + "profile_title": None, + "profile_description": None, + "profile_cover": None, + "profile_user_id": None, + "profile_user_name": None, + "design_model_id": None, + "design_profile_id": None, + "design_region": None, + "model_pictures": [], + "profile_pictures": [], + "thumbnails": [], + } + + try: + with zipfile.ZipFile(self.file_path, "r") as zf: + # Parse 3D/3dmodel.model for metadata + model_path = "3D/3dmodel.model" + if model_path in zf.namelist(): + content = zf.read(model_path).decode("utf-8", errors="ignore") + + # Extract metadata elements using regex + # Format: Value or + metadata_pattern = r']*>([^<]*)' + matches = re.findall(metadata_pattern, content) + + field_mapping = { + "Title": "title", + "Description": "description", + "Designer": "designer", + "DesignerUserId": "designer_user_id", + "License": "license", + "Copyright": "copyright", + "CreationDate": "creation_date", + "ModificationDate": "modification_date", + "Origin": "origin", + "ProfileTitle": "profile_title", + "ProfileDescription": "profile_description", + "ProfileCover": "profile_cover", + "ProfileUserId": "profile_user_id", + "ProfileUserName": "profile_user_name", + "DesignModelId": "design_model_id", + "DesignProfileId": "design_profile_id", + "DesignRegion": "design_region", + } + + for name, value in matches: + if name in field_mapping: + # Decode HTML entities multiple times (content is often triple-encoded) + decoded = value.strip() + prev = None + while prev != decoded: + prev = decoded + decoded = html.unescape(decoded) + # Normalize non-breaking spaces to regular spaces + decoded = decoded.replace('\xa0', ' ') + result[field_mapping[name]] = decoded if decoded else None + + # List images in Auxiliaries folder + from urllib.parse import quote + for name in zf.namelist(): + if name.startswith("Auxiliaries/Model Pictures/"): + filename = name.split("/")[-1] + if filename: + result["model_pictures"].append({ + "name": filename, + "path": name, + "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}", + }) + elif name.startswith("Auxiliaries/Profile Pictures/"): + filename = name.split("/")[-1] + if filename: + result["profile_pictures"].append({ + "name": filename, + "path": name, + "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}", + }) + elif name.startswith("Auxiliaries/.thumbnails/"): + filename = name.split("/")[-1] + if filename: + result["thumbnails"].append({ + "name": filename, + "path": name, + "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}", + }) + + except Exception as e: + result["_error"] = str(e) + + return result + + def get_image(self, image_path: str) -> tuple[bytes, str] | None: + """Extract an image from the 3MF file. + + Returns tuple of (image_data, content_type) or None if not found. + """ + try: + with zipfile.ZipFile(self.file_path, "r") as zf: + if image_path in zf.namelist(): + data = zf.read(image_path) + # Determine content type from extension + ext = image_path.lower().split(".")[-1] + content_types = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "webp": "image/webp", + "gif": "image/gif", + } + content_type = content_types.get(ext, "application/octet-stream") + return (data, content_type) + except Exception: + pass + return None + + def update_metadata(self, updates: dict) -> bool: + """Update project page metadata in the 3MF file. + + Args: + updates: Dict with fields to update (title, description, designer, etc.) + + Returns: + True if successful, False otherwise. + """ + import html + import re + import tempfile + + try: + # Read the 3MF file + with zipfile.ZipFile(self.file_path, "r") as zf_read: + # Find and read the 3dmodel.model file + model_path = "3D/3dmodel.model" + if model_path not in zf_read.namelist(): + return False + + content = zf_read.read(model_path).decode("utf-8") + + # Update metadata fields + field_mapping = { + "title": "Title", + "description": "Description", + "designer": "Designer", + "license": "License", + "copyright": "Copyright", + "profile_title": "ProfileTitle", + "profile_description": "ProfileDescription", + } + + for field, xml_name in field_mapping.items(): + if field in updates and updates[field] is not None: + new_value = html.escape(updates[field]) + # Replace existing metadata or we'd need to add it + pattern = rf'(]*>)[^<]*()' + replacement = rf'\g<1>{new_value}\g<2>' + content = re.sub(pattern, replacement, content) + + # Write to a temporary file first + with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp: + tmp_path = Path(tmp.name) + + # Create new zip with updated content + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write: + for item in zf_read.namelist(): + if item == model_path: + zf_write.writestr(item, content.encode("utf-8")) + else: + zf_write.writestr(item, zf_read.read(item)) + + # Replace original file with updated one + shutil.move(tmp_path, self.file_path) + return True + + except Exception: + # Clean up temp file if it exists + if "tmp_path" in locals() and tmp_path.exists(): + tmp_path.unlink() + return False + + class ArchiveService: """Service for archiving print jobs.""" def __init__(self, db: AsyncSession): self.db = db + @staticmethod + def compute_file_hash(file_path: Path) -> str: + """Compute SHA256 hash of a file for duplicate detection.""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + # Read in chunks to handle large files + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest() + + async def get_duplicate_hashes(self) -> set[str]: + """Get all content hashes that appear more than once. + + Returns a set of hashes that have duplicates. + """ + from sqlalchemy import func + + result = await self.db.execute( + select(PrintArchive.content_hash) + .where(PrintArchive.content_hash.isnot(None)) + .group_by(PrintArchive.content_hash) + .having(func.count(PrintArchive.id) > 1) + ) + return {row[0] for row in result.all()} + + async def find_duplicates( + self, + archive_id: int, + content_hash: str | None = None, + print_name: str | None = None, + makerworld_model_id: str | None = None, + ) -> list[dict]: + """Find duplicate archives based on hash or name matching. + + Returns list of dicts with id, print_name, created_at, match_type. + """ + duplicates = [] + + # First, find exact matches by content hash + if content_hash: + result = await self.db.execute( + select(PrintArchive) + .where( + and_( + PrintArchive.content_hash == content_hash, + PrintArchive.id != archive_id, + ) + ) + .order_by(PrintArchive.created_at.desc()) + .limit(10) + ) + for archive in result.scalars().all(): + duplicates.append({ + "id": archive.id, + "print_name": archive.print_name, + "created_at": archive.created_at, + "match_type": "exact", + }) + + # Then, find similar matches by print name or MakerWorld ID + if print_name or makerworld_model_id: + conditions = [PrintArchive.id != archive_id] + + name_conditions = [] + if print_name: + # Match if print names are similar (ignoring case) + name_conditions.append(PrintArchive.print_name.ilike(print_name)) + if makerworld_model_id: + # Match by MakerWorld model ID stored in extra_data + name_conditions.append( + PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id + ) + + if name_conditions: + conditions.append(or_(*name_conditions)) + + result = await self.db.execute( + select(PrintArchive) + .where(and_(*conditions)) + .order_by(PrintArchive.created_at.desc()) + .limit(10) + ) + for archive in result.scalars().all(): + # Don't add if already in duplicates (exact match) + if not any(d["id"] == archive.id for d in duplicates): + duplicates.append({ + "id": archive.id, + "print_name": archive.print_name, + "created_at": archive.created_at, + "match_type": "similar", + }) + + return duplicates + async def archive_print( self, printer_id: int | None, @@ -298,6 +593,9 @@ class ArchiveService: dest_file = archive_dir / source_file.name shutil.copy2(source_file, dest_file) + # Compute content hash for duplicate detection + content_hash = self.compute_file_hash(dest_file) + # Parse 3MF metadata parser = ThreeMFParser(dest_file) metadata = parser.parse() @@ -326,6 +624,7 @@ class ArchiveService: filename=source_file.name, file_path=str(dest_file.relative_to(settings.base_dir)), file_size=dest_file.stat().st_size, + content_hash=content_hash, thumbnail_path=thumbnail_path, print_name=metadata.get("print_name") or source_file.stem, print_time_seconds=metadata.get("print_time_seconds"), diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 9384f6177..69aca4da4 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -1,12 +1,32 @@ import json import ssl import asyncio +from collections import deque +from datetime import datetime from typing import Callable from dataclasses import dataclass, field import paho.mqtt.client as mqtt +@dataclass +class MQTTLogEntry: + """Log entry for MQTT message debugging.""" + timestamp: str + topic: str + direction: str # "in" or "out" + payload: dict + + +@dataclass +class HMSError: + """Health Management System error from printer.""" + code: str + module: int + severity: int # 1=fatal, 2=serious, 3=common, 4=info + message: str = "" + + @dataclass class PrinterState: connected: bool = False @@ -21,6 +41,7 @@ class PrinterState: raw_data: dict = field(default_factory=dict) gcode_file: str | None = None subtask_id: str | None = None + hms_errors: list = field(default_factory=list) # List of HMSError class BambuMQTTClient: @@ -49,6 +70,8 @@ class BambuMQTTClient: self._loop: asyncio.AbstractEventLoop | None = None self._previous_gcode_state: str | None = None self._previous_gcode_file: str | None = None + self._message_log: deque[MQTTLogEntry] = deque(maxlen=100) + self._logging_enabled: bool = False @property def topic_subscribe(self) -> str: @@ -75,6 +98,14 @@ class BambuMQTTClient: def _on_message(self, client, userdata, msg): try: payload = json.loads(msg.payload.decode()) + # Log message if logging is enabled + if self._logging_enabled: + self._message_log.append(MQTTLogEntry( + timestamp=datetime.now().isoformat(), + topic=msg.topic, + direction="in", + payload=payload, + )) self._process_message(payload) except json.JSONDecodeError: pass @@ -121,11 +152,42 @@ class BambuMQTTClient: temps["nozzle"] = float(data["nozzle_temper"]) if "nozzle_target_temper" in data: temps["nozzle_target"] = float(data["nozzle_target_temper"]) + # Second nozzle for dual-extruder printers (H2 series) + if "nozzle_temper_2" in data: + temps["nozzle_2"] = float(data["nozzle_temper_2"]) + if "nozzle_target_temper_2" in data: + temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"]) if "chamber_temper" in data: temps["chamber"] = float(data["chamber_temper"]) if temps: self.state.temperatures = temps + # Parse HMS (Health Management System) errors + if "hms" in data: + hms_list = data["hms"] + self.state.hms_errors = [] + if isinstance(hms_list, list): + for hms in hms_list: + if isinstance(hms, dict): + # HMS format: {"attr": code, "code": full_code} + # The code is a hex string, severity is in bits + code = hms.get("code", hms.get("attr", "0")) + if isinstance(code, int): + code = hex(code) + # Parse severity from code (typically last 4 bits indicate level) + try: + code_int = int(str(code).replace("0x", ""), 16) if code else 0 + severity = (code_int >> 16) & 0xF # Extract severity bits + module = (code_int >> 24) & 0xFF # Extract module bits + except (ValueError, TypeError): + severity = 3 + module = 0 + self.state.hms_errors.append(HMSError( + code=str(code), + module=module, + severity=severity if severity > 0 else 3, + )) + self.state.raw_data = data # Detect print start (state changes TO RUNNING with a file) @@ -231,4 +293,31 @@ class BambuMQTTClient: def send_command(self, command: dict): """Send a command to the printer.""" if self._client and self.state.connected: + # Log outgoing message if logging is enabled + if self._logging_enabled: + self._message_log.append(MQTTLogEntry( + timestamp=datetime.now().isoformat(), + topic=self.topic_publish, + direction="out", + payload=command, + )) self._client.publish(self.topic_publish, json.dumps(command)) + + def enable_logging(self, enabled: bool = True): + """Enable or disable MQTT message logging.""" + self._logging_enabled = enabled + if not enabled: + self._message_log.clear() + + def get_logs(self) -> list[MQTTLogEntry]: + """Get all logged MQTT messages.""" + return list(self._message_log) + + def clear_logs(self): + """Clear the message log.""" + self._message_log.clear() + + @property + def logging_enabled(self) -> bool: + """Check if logging is enabled.""" + return self._logging_enabled diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 3d629501f..e2cf7073e 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -6,7 +6,7 @@ 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_mqtt import BambuMQTTClient, PrinterState, MQTTLogEntry from backend.app.services.bambu_ftp import BambuFTPClient @@ -118,6 +118,32 @@ class PrinterManager: return self._clients[printer_id].start_print(filename) return False + def enable_logging(self, printer_id: int, enabled: bool = True) -> bool: + """Enable or disable MQTT logging for a printer.""" + if printer_id in self._clients: + self._clients[printer_id].enable_logging(enabled) + return True + return False + + def get_logs(self, printer_id: int) -> list[MQTTLogEntry]: + """Get MQTT logs for a printer.""" + if printer_id in self._clients: + return self._clients[printer_id].get_logs() + return [] + + def clear_logs(self, printer_id: int) -> bool: + """Clear MQTT logs for a printer.""" + if printer_id in self._clients: + self._clients[printer_id].clear_logs() + return True + return False + + def is_logging_enabled(self, printer_id: int) -> bool: + """Check if logging is enabled for a printer.""" + if printer_id in self._clients: + return self._clients[printer_id].logging_enabled + return False + async def test_connection( self, ip_address: str, @@ -159,6 +185,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) -> "layer_num": state.layer_num, "total_layers": state.total_layers, "temperatures": state.temperatures, + "hms_errors": [ + {"code": e.code, "module": e.module, "severity": e.severity} + for e in (state.hms_errors or []) + ], } # 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: diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py new file mode 100644 index 000000000..3fb228dbd --- /dev/null +++ b/backend/app/services/smart_plug_manager.py @@ -0,0 +1,250 @@ +"""Manager for smart plug automation and delayed turn-off.""" + +import asyncio +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from backend.app.services.tasmota import tasmota_service +from backend.app.services.printer_manager import printer_manager + +if TYPE_CHECKING: + from backend.app.models.smart_plug import SmartPlug + +logger = logging.getLogger(__name__) + + +class SmartPlugManager: + """Manages smart plug automation and delayed turn-off.""" + + def __init__(self): + self._pending_off: dict[int, asyncio.Task] = {} # plug_id -> task + self._loop: asyncio.AbstractEventLoop | None = None + + def set_event_loop(self, loop: asyncio.AbstractEventLoop): + """Set the event loop for async operations.""" + self._loop = loop + + async def _get_plug_for_printer( + self, printer_id: int, db: AsyncSession + ) -> "SmartPlug | None": + """Get the smart plug linked to a printer.""" + from backend.app.models.smart_plug import SmartPlug + + result = await db.execute( + select(SmartPlug).where(SmartPlug.printer_id == printer_id) + ) + return result.scalar_one_or_none() + + async def on_print_start(self, printer_id: int, db: AsyncSession): + """Called when a print starts - turn on plug if configured.""" + plug = await self._get_plug_for_printer(printer_id, db) + + if not plug: + return + + if not plug.enabled: + logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-on") + return + + if not plug.auto_on: + logger.debug(f"Smart plug '{plug.name}' auto_on is disabled") + return + + # Cancel any pending off task + self._cancel_pending_off(plug.id) + + # Turn on the plug + logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'") + success = await tasmota_service.turn_on(plug) + + if success: + # Update last state + plug.last_state = "ON" + plug.last_checked = datetime.utcnow() + await db.commit() + + async def on_print_complete( + self, printer_id: int, status: str, db: AsyncSession + ): + """Called when a print completes - schedule turn off if configured.""" + plug = await self._get_plug_for_printer(printer_id, db) + + if not plug: + return + + if not plug.enabled: + logger.debug(f"Smart plug '{plug.name}' is disabled, skipping auto-off") + return + + if not plug.auto_off: + logger.debug(f"Smart plug '{plug.name}' auto_off is disabled") + return + + logger.info( + f"Print completed on printer {printer_id} (status: {status}), " + f"scheduling turn-off for plug '{plug.name}'" + ) + + if plug.off_delay_mode == "time": + self._schedule_delayed_off(plug, plug.off_delay_minutes * 60) + elif plug.off_delay_mode == "temperature": + self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold) + + def _schedule_delayed_off(self, plug: "SmartPlug", delay_seconds: int): + """Schedule turn-off after delay.""" + # Cancel any existing task for this plug + self._cancel_pending_off(plug.id) + + logger.info( + f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds" + ) + + task = asyncio.create_task( + self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, delay_seconds) + ) + self._pending_off[plug.id] = task + + async def _delayed_off( + self, + plug_id: int, + ip_address: str, + username: str | None, + password: str | None, + delay_seconds: int, + ): + """Wait and turn off.""" + try: + await asyncio.sleep(delay_seconds) + + # Create a minimal plug-like object for the tasmota service + class PlugInfo: + def __init__(self): + self.ip_address = ip_address + self.username = username + self.password = password + self.name = f"plug_{plug_id}" + + plug_info = PlugInfo() + await tasmota_service.turn_off(plug_info) + logger.info(f"Turned off plug {plug_id} after time delay") + + except asyncio.CancelledError: + logger.debug(f"Delayed turn-off cancelled for plug {plug_id}") + finally: + self._pending_off.pop(plug_id, None) + + def _schedule_temp_based_off( + self, plug: "SmartPlug", printer_id: int, temp_threshold: int + ): + """Monitor temperature and turn off when below threshold.""" + # Cancel any existing task for this plug + self._cancel_pending_off(plug.id) + + logger.info( + f"Scheduling temperature-based turn-off for plug '{plug.name}' " + f"(threshold: {temp_threshold}°C)" + ) + + task = asyncio.create_task( + self._temp_based_off( + plug.id, + plug.ip_address, + plug.username, + plug.password, + printer_id, + temp_threshold, + ) + ) + self._pending_off[plug.id] = task + + async def _temp_based_off( + self, + plug_id: int, + ip_address: str, + username: str | None, + password: str | None, + printer_id: int, + temp_threshold: int, + ): + """Poll temperature until below threshold, then turn off. + + For dual-extruder printers (H2 series), checks both nozzles. + """ + try: + check_interval = 10 # seconds + max_wait = 3600 # 1 hour max + elapsed = 0 + + while elapsed < max_wait: + status = printer_manager.get_status(printer_id) + + if status: + temps = status.temperatures or {} + nozzle_temp = temps.get("nozzle", 999) + # Check second nozzle for dual-extruder printers (H2 series) + nozzle_2_temp = temps.get("nozzle_2") + + # Get the maximum temperature across all nozzles + max_nozzle_temp = nozzle_temp + if nozzle_2_temp is not None: + max_nozzle_temp = max(nozzle_temp, nozzle_2_temp) + logger.debug( + f"Checking temp for plug {plug_id}: nozzle1={nozzle_temp}°C, " + f"nozzle2={nozzle_2_temp}°C, max={max_nozzle_temp}°C, " + f"threshold={temp_threshold}°C" + ) + else: + logger.debug( + f"Checking temp for plug {plug_id}: nozzle={nozzle_temp}°C, " + f"threshold={temp_threshold}°C" + ) + + if max_nozzle_temp < temp_threshold: + # All nozzles are below threshold, turn off + class PlugInfo: + def __init__(self): + self.ip_address = ip_address + self.username = username + self.password = password + self.name = f"plug_{plug_id}" + + plug_info = PlugInfo() + await tasmota_service.turn_off(plug_info) + logger.info( + f"Turned off plug {plug_id} after nozzle temp dropped to " + f"{max_nozzle_temp}°C (threshold: {temp_threshold}°C)" + ) + break + + await asyncio.sleep(check_interval) + elapsed += check_interval + + if elapsed >= max_wait: + logger.warning( + f"Temperature-based turn-off timed out for plug {plug_id} after {max_wait}s" + ) + + except asyncio.CancelledError: + logger.debug(f"Temperature-based turn-off cancelled for plug {plug_id}") + finally: + self._pending_off.pop(plug_id, None) + + def _cancel_pending_off(self, plug_id: int): + """Cancel any pending off task for this plug.""" + if plug_id in self._pending_off: + logger.debug(f"Cancelling pending turn-off for plug {plug_id}") + self._pending_off[plug_id].cancel() + del self._pending_off[plug_id] + + def cancel_all_pending(self): + """Cancel all pending turn-off tasks.""" + for plug_id in list(self._pending_off.keys()): + self._cancel_pending_off(plug_id) + + +# Global singleton +smart_plug_manager = SmartPlugManager() diff --git a/backend/app/services/tasmota.py b/backend/app/services/tasmota.py new file mode 100644 index 000000000..e069aea4f --- /dev/null +++ b/backend/app/services/tasmota.py @@ -0,0 +1,195 @@ +"""Service for communicating with Tasmota devices via HTTP API.""" + +import asyncio +import logging +from datetime import datetime +from typing import TYPE_CHECKING + +import httpx + +if TYPE_CHECKING: + from backend.app.models.smart_plug import SmartPlug + +logger = logging.getLogger(__name__) + + +class TasmotaService: + """Service for communicating with Tasmota devices via HTTP API.""" + + def __init__(self, timeout: float = 5.0): + self.timeout = timeout + + def _build_url( + self, + ip: str, + command: str, + username: str | None = None, + password: str | None = None, + ) -> str: + """Build Tasmota command URL.""" + # URL encode the command + cmd = command.replace(" ", "%20") + + if username and password: + return f"http://{username}:{password}@{ip}/cm?cmnd={cmd}" + return f"http://{ip}/cm?cmnd={cmd}" + + async def _send_command( + self, + ip: str, + command: str, + username: str | None = None, + password: str | None = None, + ) -> dict | None: + """Send a command to a Tasmota device and return the response.""" + url = self._build_url(ip, command, username, password) + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get(url) + response.raise_for_status() + return response.json() + except httpx.TimeoutException: + logger.warning(f"Tasmota device at {ip} timed out") + return None + except httpx.HTTPStatusError as e: + logger.warning(f"Tasmota device at {ip} returned error: {e}") + return None + except httpx.RequestError as e: + logger.warning(f"Failed to connect to Tasmota device at {ip}: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error communicating with Tasmota at {ip}: {e}") + return None + + async def get_status(self, plug: "SmartPlug") -> dict: + """Get current power state and device info. + + Returns dict with: + - state: "ON" or "OFF" or None if unreachable + - reachable: bool + - device_name: str or None + """ + result = await self._send_command( + plug.ip_address, "Power", plug.username, plug.password + ) + + if result is None: + return {"state": None, "reachable": False, "device_name": None} + + # Response format: {"POWER":"ON"} or {"POWER":"OFF"} + # Some devices use {"POWER1":"ON"} for multi-relay + state = None + for key in ["POWER", "POWER1"]: + if key in result: + state = result[key] + break + + return {"state": state, "reachable": True, "device_name": None} + + async def turn_on(self, plug: "SmartPlug") -> bool: + """Turn on the plug. Returns True if successful.""" + result = await self._send_command( + plug.ip_address, "Power On", plug.username, plug.password + ) + + if result is None: + return False + + # Check if the command was successful + state = result.get("POWER") or result.get("POWER1") + success = state == "ON" + + if success: + logger.info(f"Turned ON smart plug '{plug.name}' at {plug.ip_address}") + else: + logger.warning( + f"Failed to turn ON smart plug '{plug.name}' at {plug.ip_address}" + ) + + return success + + async def turn_off(self, plug: "SmartPlug") -> bool: + """Turn off the plug. Returns True if successful.""" + result = await self._send_command( + plug.ip_address, "Power Off", plug.username, plug.password + ) + + if result is None: + return False + + # Check if the command was successful + state = result.get("POWER") or result.get("POWER1") + success = state == "OFF" + + if success: + logger.info(f"Turned OFF smart plug '{plug.name}' at {plug.ip_address}") + else: + logger.warning( + f"Failed to turn OFF smart plug '{plug.name}' at {plug.ip_address}" + ) + + return success + + async def toggle(self, plug: "SmartPlug") -> bool: + """Toggle the plug state. Returns True if successful.""" + result = await self._send_command( + plug.ip_address, "Power Toggle", plug.username, plug.password + ) + + if result is None: + return False + + state = result.get("POWER") or result.get("POWER1") + success = state in ["ON", "OFF"] + + if success: + logger.info( + f"Toggled smart plug '{plug.name}' at {plug.ip_address} to {state}" + ) + + return success + + async def test_connection( + self, + ip: str, + username: str | None = None, + password: str | None = None, + ) -> dict: + """Test connection to a Tasmota device. + + Returns dict with: + - success: bool + - state: current power state or None + - device_name: device name or None + - error: error message if failed + """ + # Try to get power status + result = await self._send_command(ip, "Power", username, password) + + if result is None: + return { + "success": False, + "state": None, + "device_name": None, + "error": "Could not connect to device", + } + + state = result.get("POWER") or result.get("POWER1") + + # Try to get device name + status_result = await self._send_command(ip, "Status 0", username, password) + device_name = None + if status_result and "Status" in status_result: + device_name = status_result["Status"].get("DeviceName") + + return { + "success": True, + "state": state, + "device_name": device_name, + "error": None, + } + + +# Singleton instance +tasmota_service = TasmotaService() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bbeb58e8f..383cbdd22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,14 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.90.11", + "@tiptap/extension-color": "^3.11.1", + "@tiptap/extension-image": "^3.11.1", + "@tiptap/extension-link": "^3.11.1", + "@tiptap/extension-text-align": "^3.11.1", + "@tiptap/extension-text-style": "^3.11.1", + "@tiptap/extension-underline": "^3.11.1", + "@tiptap/react": "^3.11.1", + "@tiptap/starter-kit": "^3.11.1", "@types/three": "^0.181.0", "gcode-preview": "^2.18.0", "jszip": "^3.10.1", @@ -996,6 +1004,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1134,6 +1159,12 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -1758,6 +1789,497 @@ "react": "^18 || ^19" } }, + "node_modules/@tiptap/core": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz", + "integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.11.1.tgz", + "integrity": "sha512-c3DN5c/9kl8w1wCcylH9XqW0OyCegqE3EL4rDlVYkyBD0GwCnUS30pN+jdxCUq/tl94lkkRk7XMyEUwzQmG+5g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.11.1.tgz", + "integrity": "sha512-ee/OoAPViUAgJb8dxF7D2YSSYUWcw8RXqhNSDx15w58rxpYbJbvOv3WDMrGNvl4M9nuwXYfXc3iPl/eYtwHx2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.11.1.tgz", + "integrity": "sha512-rLgU2drvoSTpdXEmoo61ZSmtRR44vMeS36OoDpUA1dNzo/vWAiOzQeLnm8gC9cD2TmvJ+WIe7tOkpAEfw4kmiQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.11.1.tgz", + "integrity": "sha512-6fj0b0Ynam8FMsP3NiCZ4a2uP7lCBHDFBXfcRwFDOqAgBIPvIK+r6CuHEGothGaF7EeQ9MTyj9fwlGjyHsPQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.11.1.tgz", + "integrity": "sha512-R3HtNPAuaKqbDwK1uWO/6QFHXbbKcxbV27XVCVtTQ4gCAzIZbJElp9REEqAOp/zI6bqt774UrAekeV+5i8NQYw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.11.1.tgz", + "integrity": "sha512-Bk7mmA+m510zzLG5AMFmywrL50NlBA5p7bR0cKfdp4ckXr8FohxH3QS0Woy1MRnFUGRtIzJkSYQTJ3O/G1lBqQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-color": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.11.1.tgz", + "integrity": "sha512-ZKK0ZbqlpmYgtRBJcSxDPtMgmPQBoQv7I7xTMF1+E4DbwSgU7HPUkGLDOcc/ezmWfZLyMKXgKmvy+54nuut4vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.11.1.tgz", + "integrity": "sha512-Px8T7Kv8EEiFpM/h13Rro8HoynrlK8zA3u3ekHq/FBSTXnPtqPAUYNx/DUhIrLs3eWWJ8+P0Onm+sVLZmaLMug==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.11.1.tgz", + "integrity": "sha512-+tmWD/4tg7Mt1TArrvc1Gna1FiSyru2rE6sapEerXCH3RFfaqGBeMqeRaOeZrCiqB+vIsXfthHDC/7xz5rFp/g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.11.1.tgz", + "integrity": "sha512-HGF04KhDn1oAD+2/zStSWeGIgR41l/raf64h/Rwkvde5Sf2g3BPRW4M1ESS6e2Rjw74Kwa4/xNO6CzZNid6yRg==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.11.1.tgz", + "integrity": "sha512-rZ4eIFOPrLPM0bAMW560v/i9WeAz6D6PPtmFJ/Rwh7F5QFbg+jSXAyGvg7V9ZwzA5OaXqsToyJBR7qtGXBXAhQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.11.1.tgz", + "integrity": "sha512-JMp6CizdB7LoY2jmaZub2D+Aj6RJTkSu0EhIcN/bmBrm4MjYa/ir6nRoo4/gYGIHzHwgwGR/1KmlqTJZW/xl4g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.11.1.tgz", + "integrity": "sha512-b9ShCSQhWXNzdbdn9a3j33cq646nS0EpVyNBQr0BMOpIcMI4Ot8LGEvPo0BNqPPvpjMJaP2N6xp+EIdk6tunfQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.11.1.tgz", + "integrity": "sha512-9zr6dItcJvzZtFlC+dyFb5VfWGzKzldPAOuln1d/GwKrBZds53O2vBmu4Jxfy22N9LuwiGB+2PYerq0UkLnxnA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.11.1.tgz", + "integrity": "sha512-lI+FyyHavUXHmDKxvSAdqGAvaYtVesAxHckeA60ZjZu9fBkUnVWHD8uR0TStX7EdOIRBWpzYrG7dDT4EkFVjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.11.1.tgz", + "integrity": "sha512-SrjsU+gvhjoPKelc4YSeC2AQ0lhwLiDWMO7fW83CVitCF8iWXpBSeVCI5SxtPeZNKTZ1ZCc3lIQCeEHOC/gP0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.11.1.tgz", + "integrity": "sha512-ds5auQnWGcHwC2/c1iEvvybdLPcSDlxsii7FPaZg4LaSGdNojRB0qDRZw5dzYQZbfIf5vgYGcIVCVjNPZs1UwQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz", + "integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.11.1.tgz", + "integrity": "sha512-KFw3TAwN6hQ+oDeE3lRqwzCRKhxU1NWf9q5SAwiUxlp/LcEjuhXcYJYX8SHPOLOlTo0P42v1i0KBeLUPKnO58g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.11.1.tgz", + "integrity": "sha512-MpeuVi+bOHulbN65bOjaeoNJstNuAAEPdLwNjW25c9y2a8b5iZFY8vdVNENDiqq+dI+F5EaFGaEq0FN0uslfiA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.11.1.tgz", + "integrity": "sha512-Aphq0kfk6J/hNQennJ+bntvDzqRPT7RVpnow1s4U4dLBsR6PP7X4zEBg96uAv2OW0RjDHFK9NFqpJPbZtQTmFw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.11.1.tgz", + "integrity": "sha512-a3lm1WvYewAP2IESq+qnbOtLSJ9yULY2Bj/6DvBq9fzWpb2gSlUdElYh6JLunxB1HEPECTuuRsNPdTrMsSpV4g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.11.1.tgz", + "integrity": "sha512-1LfkHNkrGR509cPRgcMr95+nWcAHE0JDm9LkuzdexunhCfJ2tl/h1rA14x3sic8GxQFqEnMefvBUpUbQwPydYw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.11.1.tgz", + "integrity": "sha512-5E94ggkFAZ7OSFSwnofAsmxqmSStRoeCB8AnRuWrR+nnXi43Rq7yptdejQaLi13Z9fSVdnF6h+pB3ua2Exg6WQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.11.1.tgz", + "integrity": "sha512-lZvM8HF4qlHuXX1u0ngj1Si1zVzWhS+RiF5kczScul+F1lEQgK+ugL6iF87MSc1yxw5eZQDpA0byx1N+ZqZWZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.11.1.tgz", + "integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.11.1.tgz", + "integrity": "sha512-Y3EJxfE1g4XSGbUZN+74o38mp3O+BQXtlqxAQvedzXiGGrdK2kWhp2b4nj3IkxHdRdoSijf+oZzgyBrRDdgC/w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz", + "integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz", + "integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.11.1.tgz", + "integrity": "sha512-aPInZbpSWYzJvCFXaY6EhxD+H5ITURElUmUXBoRvlAB6QrR6NIWBt68hNe8i+aDGmuvLS18g60HWK5S6K2RjWQ==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3.1.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.11.1", + "@tiptap/extension-floating-menu": "^3.11.1" + }, + "peerDependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/pm": "^3.11.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.11.1", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.11.1.tgz", + "integrity": "sha512-weRrhp0p5J6cMNcybYobhbOVrgym7KYIwBblJ/1M1snykg+avZawVk2M5Y7j9gM1p2zo112MCw8z8nOa9Yrwow==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.11.1", + "@tiptap/extension-blockquote": "^3.11.1", + "@tiptap/extension-bold": "^3.11.1", + "@tiptap/extension-bullet-list": "^3.11.1", + "@tiptap/extension-code": "^3.11.1", + "@tiptap/extension-code-block": "^3.11.1", + "@tiptap/extension-document": "^3.11.1", + "@tiptap/extension-dropcursor": "^3.11.1", + "@tiptap/extension-gapcursor": "^3.11.1", + "@tiptap/extension-hard-break": "^3.11.1", + "@tiptap/extension-heading": "^3.11.1", + "@tiptap/extension-horizontal-rule": "^3.11.1", + "@tiptap/extension-italic": "^3.11.1", + "@tiptap/extension-link": "^3.11.1", + "@tiptap/extension-list": "^3.11.1", + "@tiptap/extension-list-item": "^3.11.1", + "@tiptap/extension-list-keymap": "^3.11.1", + "@tiptap/extension-ordered-list": "^3.11.1", + "@tiptap/extension-paragraph": "^3.11.1", + "@tiptap/extension-strike": "^3.11.1", + "@tiptap/extension-text": "^3.11.1", + "@tiptap/extension-underline": "^3.11.1", + "@tiptap/extensions": "^3.11.1", + "@tiptap/pm": "^3.11.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -1886,6 +2408,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -1901,7 +2445,6 @@ "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": { @@ -1912,8 +2455,8 @@ "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", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2310,7 +2853,6 @@ "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": { @@ -2524,6 +3066,12 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2543,7 +3091,6 @@ "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": { @@ -2729,6 +3276,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-toolkit": { "version": "1.42.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", @@ -2795,7 +3354,6 @@ "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" @@ -2999,7 +3557,6 @@ "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": { @@ -3706,6 +4263,21 @@ "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3758,6 +4330,29 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/meshoptimizer": { "version": "0.22.0", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", @@ -3845,6 +4440,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -3990,6 +4591,204 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/prosemirror-changeset": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", + "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", + "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", + "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", + "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "peer": true, + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz", + "integrity": "sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.25.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.10.3", + "prosemirror-view": "^1.39.1" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", + "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", + "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4000,6 +4799,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -4221,6 +5029,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -4444,6 +5258,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -4605,6 +5425,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f66791dfa..e61669b6a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,14 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tanstack/react-query": "^5.90.11", + "@tiptap/extension-color": "^3.11.1", + "@tiptap/extension-image": "^3.11.1", + "@tiptap/extension-link": "^3.11.1", + "@tiptap/extension-text-align": "^3.11.1", + "@tiptap/extension-text-style": "^3.11.1", + "@tiptap/extension-underline": "^3.11.1", + "@tiptap/react": "^3.11.1", + "@tiptap/starter-kit": "^3.11.1", "@types/three": "^0.181.0", "gcode-preview": "^2.18.0", "jszip": "^3.10.1", diff --git a/frontend/public/img/android-chrome-192x192.png b/frontend/public/img/android-chrome-192x192.png new file mode 100644 index 000000000..a394aed35 Binary files /dev/null and b/frontend/public/img/android-chrome-192x192.png differ diff --git a/frontend/public/img/android-chrome-512x512.png b/frontend/public/img/android-chrome-512x512.png new file mode 100644 index 000000000..1f3509f6d Binary files /dev/null and b/frontend/public/img/android-chrome-512x512.png differ diff --git a/frontend/public/img/apple-touch-icon.png b/frontend/public/img/apple-touch-icon.png new file mode 100644 index 000000000..69e136c42 Binary files /dev/null and b/frontend/public/img/apple-touch-icon.png differ diff --git a/frontend/public/img/favicon-16x16.png b/frontend/public/img/favicon-16x16.png new file mode 100644 index 000000000..df56b4740 Binary files /dev/null and b/frontend/public/img/favicon-16x16.png differ diff --git a/frontend/public/img/favicon-32x32.png b/frontend/public/img/favicon-32x32.png new file mode 100644 index 000000000..a727e0122 Binary files /dev/null and b/frontend/public/img/favicon-32x32.png differ diff --git a/frontend/public/img/favicon.png b/frontend/public/img/favicon.png new file mode 100644 index 000000000..1f3509f6d Binary files /dev/null and b/frontend/public/img/favicon.png differ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4b7a0e0bd..61649bcfa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -34,6 +34,12 @@ export interface Printer { updated_at: string; } +export interface HMSError { + code: string; + module: number; + severity: number; // 1=fatal, 2=serious, 3=common, 4=info +} + export interface PrinterStatus { id: number; name: string; @@ -54,6 +60,7 @@ export interface PrinterStatus { chamber?: number; } | null; cover_url: string | null; + hms_errors: HMSError[]; } export interface PrinterCreate { @@ -66,16 +73,28 @@ export interface PrinterCreate { } // Archive types +export interface ArchiveDuplicate { + id: number; + print_name: string | null; + created_at: string; + match_type: 'exact' | 'similar'; // 'exact' = hash match, 'similar' = name match +} + export interface Archive { id: number; printer_id: number | null; filename: string; file_path: string; file_size: number; + content_hash: string | null; thumbnail_path: string | null; timelapse_path: string | null; + duplicates: ArchiveDuplicate[] | null; + duplicate_count: number; print_name: string | null; print_time_seconds: number | null; + actual_time_seconds: number | null; // Computed from started_at/completed_at + time_accuracy: number | null; // Percentage: 100 = perfect, >100 = faster than estimated filament_used_grams: number | null; filament_type: string | null; filament_color: string | null; @@ -107,6 +126,8 @@ export interface ArchiveStats { total_cost: number; prints_by_filament_type: Record; prints_by_printer: Record; + average_time_accuracy: number | null; + time_accuracy_by_printer: Record | null; } export interface BulkUploadResult { @@ -161,6 +182,79 @@ export interface CloudDevice { online: boolean; } +// Smart Plug types +export interface SmartPlug { + id: number; + name: string; + ip_address: string; + printer_id: number | null; + enabled: boolean; + auto_on: boolean; + auto_off: boolean; + off_delay_mode: 'time' | 'temperature'; + off_delay_minutes: number; + off_temp_threshold: number; + username: string | null; + password: string | null; + last_state: string | null; + last_checked: string | null; + created_at: string; + updated_at: string; +} + +export interface SmartPlugCreate { + name: string; + ip_address: string; + printer_id?: number | null; + enabled?: boolean; + auto_on?: boolean; + auto_off?: boolean; + off_delay_mode?: 'time' | 'temperature'; + off_delay_minutes?: number; + off_temp_threshold?: number; + username?: string | null; + password?: string | null; +} + +export interface SmartPlugUpdate { + name?: string; + ip_address?: string; + printer_id?: number | null; + enabled?: boolean; + auto_on?: boolean; + auto_off?: boolean; + off_delay_mode?: 'time' | 'temperature'; + off_delay_minutes?: number; + off_temp_threshold?: number; + username?: string | null; + password?: string | null; +} + +export interface SmartPlugStatus { + state: string | null; + reachable: boolean; + device_name: string | null; +} + +export interface SmartPlugTestResult { + success: boolean; + state: string | null; + device_name: string | null; +} + +// MQTT Logging types +export interface MQTTLogEntry { + timestamp: string; + topic: string; + direction: 'in' | 'out'; + payload: Record; +} + +export interface MQTTLogsResponse { + logging_enabled: boolean; + logs: MQTTLogEntry[]; +} + // API functions export const api = { // Printers @@ -189,6 +283,22 @@ export const api = { method: 'POST', }), + // MQTT Debug Logging + enableMQTTLogging: (printerId: number) => + request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, { + method: 'POST', + }), + disableMQTTLogging: (printerId: number) => + request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/disable`, { + method: 'POST', + }), + getMQTTLogs: (printerId: number) => + request(`/printers/${printerId}/logging`), + clearMQTTLogs: (printerId: number) => + request<{ status: string }>(`/printers/${printerId}/logging`, { + method: 'DELETE', + }), + // Printer File Manager getPrinterFiles: (printerId: number, path = '/') => request<{ @@ -236,6 +346,12 @@ export const api = { deleteArchive: (id: number) => request(`/archives/${id}`, { method: 'DELETE' }), getArchiveStats: () => request('/archives/stats'), + getArchiveDuplicates: (id: number) => + request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`), + backfillContentHashes: () => + request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', { + method: 'POST', + }), getArchiveThumbnail: (id: number) => `${API_BASE}/archives/${id}/thumbnail`, getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`, getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`, @@ -286,6 +402,45 @@ export const api = { has_gcode: boolean; build_volume: { x: number; y: number; z: number }; }>(`/archives/${id}/capabilities`), + // Project Page + getArchiveProjectPage: (id: number) => + request<{ + title: string | null; + description: string | null; + designer: string | null; + designer_user_id: string | null; + license: string | null; + copyright: string | null; + creation_date: string | null; + modification_date: string | null; + origin: string | null; + profile_title: string | null; + profile_description: string | null; + profile_cover: string | null; + profile_user_id: string | null; + profile_user_name: string | null; + design_model_id: string | null; + design_profile_id: string | null; + design_region: string | null; + model_pictures: Array<{ name: string; path: string; url: string }>; + profile_pictures: Array<{ name: string; path: string; url: string }>; + thumbnails: Array<{ name: string; path: string; url: string }>; + }>(`/archives/${id}/project-page`), + updateArchiveProjectPage: (id: number, data: { + title?: string; + description?: string; + designer?: string; + license?: string; + copyright?: string; + profile_title?: string; + profile_description?: string; + }) => + request(`/archives/${id}/project-page`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + getArchiveProjectImageUrl: (archiveId: number, imagePath: string) => + `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`, getArchiveForSlicer: (id: number, filename: string) => `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`, reprintArchive: (archiveId: number, printerId: number) => @@ -360,4 +515,33 @@ export const api = { getCloudSettingDetail: (settingId: string) => request>(`/cloud/settings/${settingId}`), getCloudDevices: () => request('/cloud/devices'), + + // Smart Plugs + getSmartPlugs: () => request('/smart-plugs/'), + getSmartPlug: (id: number) => request(`/smart-plugs/${id}`), + createSmartPlug: (data: SmartPlugCreate) => + request('/smart-plugs/', { + method: 'POST', + body: JSON.stringify(data), + }), + updateSmartPlug: (id: number, data: SmartPlugUpdate) => + request(`/smart-plugs/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteSmartPlug: (id: number) => + request(`/smart-plugs/${id}`, { method: 'DELETE' }), + controlSmartPlug: (id: number, action: 'on' | 'off' | 'toggle') => + request<{ success: boolean; action: string }>(`/smart-plugs/${id}/control`, { + method: 'POST', + body: JSON.stringify({ action }), + }), + getSmartPlugStatus: (id: number) => + request(`/smart-plugs/${id}/status`), + testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null) => + request('/smart-plugs/test-connection', { + method: 'POST', + body: JSON.stringify({ ip_address, username, password }), + }), + }; diff --git a/frontend/src/components/AddSmartPlugModal.tsx b/frontend/src/components/AddSmartPlugModal.tsx new file mode 100644 index 000000000..4e62369b8 --- /dev/null +++ b/frontend/src/components/AddSmartPlugModal.tsx @@ -0,0 +1,296 @@ +import { useState, useEffect } from 'react'; +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { X, Save, Loader2, Wifi, WifiOff, CheckCircle } from 'lucide-react'; +import { api } from '../api/client'; +import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client'; +import { Button } from './Button'; + +interface AddSmartPlugModalProps { + plug?: SmartPlug | null; + onClose: () => void; +} + +export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { + const queryClient = useQueryClient(); + const isEditing = !!plug; + + const [name, setName] = useState(plug?.name || ''); + const [ipAddress, setIpAddress] = useState(plug?.ip_address || ''); + const [username, setUsername] = useState(plug?.username || ''); + const [password, setPassword] = useState(plug?.password || ''); + const [printerId, setPrinterId] = useState(plug?.printer_id || null); + const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null); + const [error, setError] = useState(null); + + // Fetch printers for linking + const { data: printers } = useQuery({ + queryKey: ['printers'], + queryFn: api.getPrinters, + }); + + // Fetch existing plugs to check for conflicts + const { data: existingPlugs } = useQuery({ + queryKey: ['smart-plugs'], + queryFn: api.getSmartPlugs, + }); + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Test connection mutation + const testMutation = useMutation({ + mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null), + onSuccess: (result) => { + setTestResult(result); + setError(null); + // Auto-fill name from device if empty + if (!name && result.device_name) { + setName(result.device_name); + } + }, + onError: (err: Error) => { + setTestResult(null); + setError(err.message); + }, + }); + + // Create mutation + const createMutation = useMutation({ + mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); + onClose(); + }, + onError: (err: Error) => { + setError(err.message); + }, + }); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); + onClose(); + }, + onError: (err: Error) => { + setError(err.message); + }, + }); + + // Filter out printers that already have a plug assigned (except current plug's printer) + const availablePrinters = printers?.filter(p => { + const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id); + return !hasPlug; + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!name.trim()) { + setError('Name is required'); + return; + } + if (!ipAddress.trim()) { + setError('IP address is required'); + return; + } + + const data = { + name: name.trim(), + ip_address: ipAddress.trim(), + username: username.trim() || null, + password: password.trim() || null, + printer_id: printerId, + }; + + if (isEditing) { + updateMutation.mutate(data); + } else { + createMutation.mutate(data); + } + }; + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ {isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'} +

+ +
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + + {/* IP Address */} +
+ +
+ { + setIpAddress(e.target.value); + setTestResult(null); + }} + placeholder="192.168.1.100" + className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> + +
+
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + <> + +
+

Connected!

+

+ {testResult.device_name && `Device: ${testResult.device_name} - `} + State: {testResult.state} +

+
+ + ) : ( + <> + + Connection failed + + )} +
+ )} + + {/* Name */} +
+ + setName(e.target.value)} + placeholder="Living Room Plug" + 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" + /> +
+ + {/* Authentication (optional) */} +
+
+ + setUsername(e.target.value)} + placeholder="admin" + 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" + /> +
+
+ + setPassword(e.target.value)} + placeholder="********" + 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" + /> +
+
+

+ Leave empty if your Tasmota device doesn't require authentication +

+ + {/* Link to Printer */} +
+ + +

+ Linking enables automatic on/off when prints start/complete +

+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 772814e92..604914dc6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -79,14 +79,12 @@ export function Layout() { className={`${sidebarExpanded ? 'w-64' : 'w-16'} bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col fixed inset-y-0 left-0 z-30 transition-all duration-300`} > {/* Logo */} -
-
- Bambusy -
+
+ Bambusy
{/* Navigation */} @@ -128,9 +126,37 @@ export function Layout() { {/* Footer */}
-
- {sidebarExpanded && v0.1.1} -
+ {sidebarExpanded ? ( +
+ v0.1.2 +
+ + + + + +
+
+ ) : ( + -
+ )}
diff --git a/frontend/src/components/MQTTDebugModal.tsx b/frontend/src/components/MQTTDebugModal.tsx new file mode 100644 index 000000000..be879d588 --- /dev/null +++ b/frontend/src/components/MQTTDebugModal.tsx @@ -0,0 +1,229 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp } from 'lucide-react'; +import { api, type MQTTLogEntry } from '../api/client'; +import { Button } from './Button'; +import { useState, useEffect, useRef } from 'react'; + +interface MQTTDebugModalProps { + printerId: number; + printerName: string; + onClose: () => void; +} + +export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) { + const queryClient = useQueryClient(); + const [autoScroll, setAutoScroll] = useState(true); + const [expandedLogs, setExpandedLogs] = useState>(new Set()); + const logContainerRef = useRef(null); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['mqtt-logs', printerId], + queryFn: () => api.getMQTTLogs(printerId), + refetchInterval: 1000, // Poll every second when logging is enabled + }); + + const enableMutation = useMutation({ + mutationFn: () => api.enableMQTTLogging(printerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] }); + }, + }); + + const disableMutation = useMutation({ + mutationFn: () => api.disableMQTTLogging(printerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] }); + }, + }); + + const clearMutation = useMutation({ + mutationFn: () => api.clearMQTTLogs(printerId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mqtt-logs', printerId] }); + }, + }); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (autoScroll && logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [data?.logs, autoScroll]); + + const toggleExpand = (index: number) => { + setExpandedLogs((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { hour12: false, fractionalSecondDigits: 3 }); + }; + + const formatPayload = (payload: Record, expanded: boolean) => { + const json = JSON.stringify(payload, null, expanded ? 2 : 0); + if (!expanded && json.length > 100) { + return json.substring(0, 100) + '...'; + } + return json; + }; + + const loggingEnabled = data?.logging_enabled ?? false; + const logs = data?.logs ?? []; + + return ( +
+
+ {/* Header */} +
+
+

MQTT Debug Log

+

{printerName}

+
+ +
+ + {/* Controls */} +
+ {loggingEnabled ? ( + + ) : ( + + )} + + +
+ + + {logs.length} message{logs.length !== 1 ? 's' : ''} + +
+ + {/* Log Content */} +
+ {logs.length === 0 ? ( +
+

No messages logged yet

+ {!loggingEnabled && ( +

Click "Start Logging" to begin capturing MQTT messages

+ )} +
+ ) : ( +
+ {logs.map((log: MQTTLogEntry, index: number) => { + const isExpanded = expandedLogs.has(index); + const isIncoming = log.direction === 'in'; + + return ( +
toggleExpand(index)} + > +
+ + {formatTimestamp(log.timestamp)} + + + {isIncoming ? ( + + ) : ( + + )} + + {log.topic} +
+
+                      {formatPayload(log.payload, isExpanded)}
+                    
+
+ ); + })} +
+ )} +
+ + {/* Footer */} +
+
+ {loggingEnabled ? ( + + + Logging active - messages will auto-refresh + + ) : ( + Logging stopped + )} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ProjectPageModal.tsx b/frontend/src/components/ProjectPageModal.tsx new file mode 100644 index 000000000..27b6c8bd9 --- /dev/null +++ b/frontend/src/components/ProjectPageModal.tsx @@ -0,0 +1,475 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + X, + User, + Calendar, + FileText, + Image, + Edit3, + Save, + ExternalLink, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { api } from '../api/client'; +import { Button } from './Button'; +import { RichTextEditor } from './RichTextEditor'; + +interface ProjectPageModalProps { + archiveId: number; + archiveName?: string; + onClose: () => void; +} + +export function ProjectPageModal({ archiveId, archiveName, onClose }: ProjectPageModalProps) { + const queryClient = useQueryClient(); + const [isEditing, setIsEditing] = useState(false); + const [selectedImageIndex, setSelectedImageIndex] = useState(null); + const [editData, setEditData] = useState<{ + title?: string; + description?: string; + designer?: string; + license?: string; + profile_title?: string; + profile_description?: string; + }>({}); + + const { data: projectPage, isLoading, error } = useQuery({ + queryKey: ['archive-project-page', archiveId], + queryFn: () => api.getArchiveProjectPage(archiveId), + }); + + const updateMutation = useMutation({ + mutationFn: (data: typeof editData) => api.updateArchiveProjectPage(archiveId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['archive-project-page', archiveId] }); + setIsEditing(false); + setEditData({}); + }, + }); + + // Handle escape key to close modal + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (selectedImageIndex !== null) { + setSelectedImageIndex(null); + } else if (isEditing) { + handleCancelEdit(); + } else { + onClose(); + } + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [selectedImageIndex, isEditing, onClose]); + + // Combine all images for gallery + const allImages = [ + ...(projectPage?.model_pictures || []), + ...(projectPage?.profile_pictures || []), + ]; + + const handleStartEdit = () => { + setEditData({ + title: projectPage?.title || '', + description: projectPage?.description || '', + designer: projectPage?.designer || '', + license: projectPage?.license || '', + profile_title: projectPage?.profile_title || '', + profile_description: projectPage?.profile_description || '', + }); + setIsEditing(true); + }; + + const handleSave = () => { + updateMutation.mutate(editData); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditData({}); + }; + + // Sanitize HTML content (basic XSS prevention) + const sanitizeHtml = (html: string) => { + // Allow basic formatting tags only + const allowed = ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'ul', 'ol', 'li', 'figure', 'img']; + const doc = new DOMParser().parseFromString(html, 'text/html'); + + const clean = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ''; + } + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + const tag = el.tagName.toLowerCase(); + + if (!allowed.includes(tag)) { + // Return children content without the tag + return Array.from(el.childNodes).map(clean).join(''); + } + + // Build allowed attributes + let attrs = ''; + if (tag === 'a' && el.getAttribute('href')) { + const href = el.getAttribute('href'); + if (href?.toLowerCase().startsWith('http')) { + attrs = ` href="${href}" target="_blank" rel="noopener noreferrer"`; + } + } + if (tag === 'img') { + const src = el.getAttribute('src'); + // Only render img if it has a valid http(s) URL, otherwise skip entirely + if (!src?.toLowerCase().startsWith('http')) { + return ''; // Skip images without valid URLs + } + attrs = ` src="${src}" style="max-width: 100%; height: auto;"`; + } + + const children = Array.from(el.childNodes).map(clean).join(''); + + if (['br', 'img'].includes(tag)) { + return `<${tag}${attrs} />`; + } + return `<${tag}${attrs}>${children}`; + } + return ''; + }; + + return Array.from(doc.body.childNodes).map(clean).join(''); + }; + + const hasContent = projectPage && ( + projectPage.title || + projectPage.description || + projectPage.designer || + projectPage.profile_title || + allImages.length > 0 + ); + + // Handle backdrop click to close modal + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ +

+ Project Page + {archiveName && - {archiveName}} +

+
+
+ {!isEditing && hasContent && ( + + )} + {isEditing && ( + <> + + + + )} + +
+
+ + {/* Content */} +
+ {isLoading && ( +
+
+
+ )} + + {error && ( +
+ Failed to load project page data +
+ )} + + {projectPage && !hasContent && ( +
+ +

No project page data found in this 3MF file.

+

+ Project pages are typically included in files downloaded from MakerWorld. +

+
+ )} + + {projectPage && hasContent && ( +
+ {/* Title & Designer */} +
+ {isEditing ? ( + setEditData({ ...editData, title: e.target.value })} + placeholder="Title" + className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-4 py-2 text-white text-xl font-semibold" + /> + ) : ( + projectPage.title && ( +

{projectPage.title}

+ ) + )} + +
+
+ + {/* Description */} + {(projectPage.description || isEditing) && ( +
+

+ Description +

+ {isEditing ? ( + setEditData({ ...editData, description: html })} + placeholder="Enter description..." + /> + ) : ( +
+ )} +
+ )} + + {/* Profile Info */} + {(projectPage.profile_title || projectPage.profile_description || isEditing) && ( +
+

+ Print Profile +

+ {isEditing ? ( +
+ setEditData({ ...editData, profile_title: e.target.value })} + placeholder="Profile Title" + className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-white" + /> + setEditData({ ...editData, profile_description: html })} + placeholder="Profile description..." + /> +
+ ) : ( + <> + {projectPage.profile_title && ( +

{projectPage.profile_title}

+ )} + {projectPage.profile_description && ( +
+ )} + {projectPage.profile_user_name && ( +

+ by {projectPage.profile_user_name} +

+ )} + + )} +
+ )} + + {/* Image Gallery */} + {allImages.length > 0 && ( +
+

+ + Images ({allImages.length}) +

+
+ {allImages.map((img, index) => ( + + ))} +
+
+ )} + + {/* MakerWorld Link */} + {projectPage.design_model_id && ( +
+ )} +
+ )} +
+
+ + {/* Image Lightbox */} + {selectedImageIndex !== null && allImages[selectedImageIndex] && ( +
setSelectedImageIndex(null)} + > + + + {allImages[selectedImageIndex].name} e.stopPropagation()} + /> + + + + + +
+ {selectedImageIndex + 1} / {allImages.length} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/RichTextEditor.tsx b/frontend/src/components/RichTextEditor.tsx new file mode 100644 index 000000000..5e0369ce5 --- /dev/null +++ b/frontend/src/components/RichTextEditor.tsx @@ -0,0 +1,191 @@ +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import Underline from '@tiptap/extension-underline'; +import TextAlign from '@tiptap/extension-text-align'; +import { TextStyle } from '@tiptap/extension-text-style'; +import Color from '@tiptap/extension-color'; +import Image from '@tiptap/extension-image'; +import { + Bold, + Italic, + Underline as UnderlineIcon, + List, + ListOrdered, + AlignLeft, + AlignCenter, + AlignRight, + Link as LinkIcon, + Unlink, +} from 'lucide-react'; + +interface RichTextEditorProps { + content: string; + onChange: (html: string) => void; + placeholder?: string; +} + +export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: false, + codeBlock: false, + code: false, + }), + Underline, + Link.configure({ + openOnClick: false, + HTMLAttributes: { + target: '_blank', + rel: 'noopener noreferrer', + }, + }), + TextAlign.configure({ + types: ['paragraph'], + }), + TextStyle, + Color, + Image.configure({ + HTMLAttributes: { + style: 'max-width: 100%; height: auto;', + }, + }), + ], + content, + onUpdate: ({ editor }) => { + onChange(editor.getHTML()); + }, + editorProps: { + attributes: { + class: 'prose prose-invert prose-sm max-w-none focus:outline-none min-h-[120px] px-3 py-2', + placeholder: placeholder || '', + }, + }, + }); + + if (!editor) { + return null; + } + + const ToolbarButton = ({ + onClick, + isActive = false, + children, + title, + }: { + onClick: () => void; + isActive?: boolean; + children: React.ReactNode; + title: string; + }) => ( + + ); + + const setLink = () => { + const url = window.prompt('Enter URL:'); + if (url) { + editor.chain().focus().setLink({ href: url }).run(); + } + }; + + return ( +
+ {/* Toolbar */} +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + title="Bold" + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + title="Italic" + > + + + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive('underline')} + title="Underline" + > + + + +
+ + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title="Bullet List" + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title="Numbered List" + > + + + +
+ + editor.chain().focus().setTextAlign('left').run()} + isActive={editor.isActive({ textAlign: 'left' })} + title="Align Left" + > + + + editor.chain().focus().setTextAlign('center').run()} + isActive={editor.isActive({ textAlign: 'center' })} + title="Align Center" + > + + + editor.chain().focus().setTextAlign('right').run()} + isActive={editor.isActive({ textAlign: 'right' })} + title="Align Right" + > + + + +
+ + + + + {editor.isActive('link') && ( + editor.chain().focus().unsetLink().run()} + title="Remove Link" + > + + + )} +
+ + {/* Editor */} + +
+ ); +} diff --git a/frontend/src/components/SmartPlugCard.tsx b/frontend/src/components/SmartPlugCard.tsx new file mode 100644 index 000000000..f0ed193b2 --- /dev/null +++ b/frontend/src/components/SmartPlugCard.tsx @@ -0,0 +1,296 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2 } from 'lucide-react'; +import { api } from '../api/client'; +import type { SmartPlug, SmartPlugUpdate } from '../api/client'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; +import { ConfirmModal } from './ConfirmModal'; + +interface SmartPlugCardProps { + plug: SmartPlug; + onEdit: (plug: SmartPlug) => void; +} + +export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { + const queryClient = useQueryClient(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + // Fetch current status + const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ + queryKey: ['smart-plug-status', plug.id], + queryFn: () => api.getSmartPlugStatus(plug.id), + refetchInterval: 30000, // Refresh every 30 seconds + }); + + // Fetch printers for linking + const { data: printers } = useQuery({ + queryKey: ['printers'], + queryFn: api.getPrinters, + }); + + const linkedPrinter = printers?.find(p => p.id === plug.printer_id); + + // Control mutation + const controlMutation = useMutation({ + mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action), + onSuccess: () => { + refetchStatus(); + }, + }); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); + }, + }); + + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: () => api.deleteSmartPlug(plug.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); + }, + }); + + const isOn = status?.state === 'ON'; + const isReachable = status?.reachable ?? false; + const isPending = controlMutation.isPending; + + return ( + <> + + + {/* Header Row */} +
+
+
+ +
+
+

{plug.name}

+

{plug.ip_address}

+
+
+ + {/* Status indicator */} +
+ {statusLoading ? ( + + ) : isReachable ? ( +
+ + {status?.state || 'Unknown'} +
+ ) : ( +
+ + Offline +
+ )} +
+
+ + {/* Linked Printer */} + {linkedPrinter && ( +
+ Linked to: + {linkedPrinter.name} +
+ )} + + {/* Quick Controls */} +
+ + +
+ + {/* Toggle Settings Panel */} + + + {/* Expanded Settings */} + {isExpanded && ( +
+ {/* Enabled Toggle */} +
+
+

Enabled

+

Enable automation for this plug

+
+ +
+ + {/* Auto On */} +
+
+

Auto On

+

Turn on when print starts

+
+ +
+ + {/* Auto Off */} +
+
+

Auto Off

+

Turn off when print completes

+
+ +
+ + {/* Delay Mode */} + {plug.auto_off && ( +
+
+

Turn Off Delay Mode

+
+ + +
+
+ + {plug.off_delay_mode === 'time' ? ( +
+ + updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" + /> +
+ ) : ( +
+ + updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" + /> +

Turns off when nozzle cools below this temperature

+
+ )} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+ )} +
+
+ + {/* Delete Confirmation */} + {showDeleteConfirm && ( + { + deleteMutation.mutate(); + setShowDeleteConfirm(false); + }} + onCancel={() => setShowDeleteConfirm(false)} + /> + )} + + ); +} diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 0b887f26e..f04599dd0 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -33,6 +33,7 @@ import { ScanSearch, QrCode, Camera, + FileText, } from 'lucide-react'; import { api } from '../api/client'; import type { Archive } from '../api/client'; @@ -48,6 +49,7 @@ import { BatchTagModal } from '../components/BatchTagModal'; import { CalendarView } from '../components/CalendarView'; import { QRCodeModal } from '../components/QRCodeModal'; import { PhotoGalleryModal } from '../components/PhotoGalleryModal'; +import { ProjectPageModal } from '../components/ProjectPageModal'; import { useToast } from '../contexts/ToastContext'; function formatFileSize(bytes: number): string { @@ -95,6 +97,7 @@ function ArchiveCard({ const [showTimelapse, setShowTimelapse] = useState(false); const [showQRCode, setShowQRCode] = useState(false); const [showPhotos, setShowPhotos] = useState(false); + const [showProjectPage, setShowProjectPage] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const timelapseScanMutation = useMutation({ @@ -210,6 +213,11 @@ function ArchiveCard({ onClick: () => setShowPhotos(true), disabled: !archive.photos?.length, }, + { + label: 'Project Page', + icon: , + onClick: () => setShowProjectPage(true), + }, { label: '', divider: true, onClick: () => {} }, { label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', @@ -285,6 +293,16 @@ function ArchiveCard({ failed
)} + {/* Duplicate badge */} + {archive.duplicate_count > 0 && ( +
+ + duplicate +
+ )} {/* Timelapse badge */} {archive.timelapse_path && ( + +
+ + +
{isLoading ? ( @@ -472,7 +544,7 @@ export function PrintersPage() { ) : (
{printers?.map((printer) => ( - + ))}
)} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 92a78fe44..c93a3c9bf 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,10 +1,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Save, RotateCcw, Loader2, Check } from 'lucide-react'; +import { Save, Loader2, Check, Plus, Plug } from 'lucide-react'; import { api } from '../api/client'; -import type { AppSettings } from '../api/client'; +import type { AppSettings, SmartPlug } from '../api/client'; import { Card, CardContent, CardHeader } from '../components/Card'; import { Button } from '../components/Button'; -import { ConfirmModal } from '../components/ConfirmModal'; +import { SmartPlugCard } from '../components/SmartPlugCard'; +import { AddSmartPlugModal } from '../components/AddSmartPlugModal'; import { useState, useEffect } from 'react'; export function SettingsPage() { @@ -12,13 +13,19 @@ export function SettingsPage() { const [localSettings, setLocalSettings] = useState(null); const [hasChanges, setHasChanges] = useState(false); const [showSaved, setShowSaved] = useState(false); - const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showPlugModal, setShowPlugModal] = useState(false); + const [editingPlug, setEditingPlug] = useState(null); const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); + const { data: smartPlugs, isLoading: plugsLoading } = useQuery({ + queryKey: ['smart-plugs'], + queryFn: api.getSmartPlugs, + }); + // Sync local state when settings load useEffect(() => { if (settings && !localSettings) { @@ -49,25 +56,12 @@ export function SettingsPage() { }, }); - const resetMutation = useMutation({ - mutationFn: api.resetSettings, - onSuccess: (data) => { - queryClient.setQueryData(['settings'], data); - setLocalSettings(data); - setHasChanges(false); - }, - }); - const handleSave = () => { if (localSettings) { updateMutation.mutate(localSettings); } }; - const handleReset = () => { - setShowResetConfirm(true); - }; - const updateSetting = (key: K, value: AppSettings[K]) => { if (localSettings) { setLocalSettings({ ...localSettings, [key]: value }); @@ -89,29 +83,19 @@ export function SettingsPage() {

Settings

Configure Bambusy

-
- - -
+
{updateMutation.isError && ( @@ -120,119 +104,171 @@ export function SettingsPage() {
)} -
- - -

Archive Settings

-
- -
+
+ {/* Left Column - General Settings */} +
+ + +

Archive Settings

+
+ +
+
+

Auto-archive prints

+

+ Automatically save 3MF files when prints complete +

+
+ +
+
+
+

Save thumbnails

+

+ Extract and save preview images from 3MF files +

+
+ +
+
+
+ + + +

Cost Tracking

+
+
-

Auto-archive prints

-

- Automatically save 3MF files when prints complete + + + updateSetting('default_filament_cost', parseFloat(e.target.value) || 0) + } + 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" + /> +

+
+ + +
+
+
+ + + +

About

+
+ +
+

Bambusy v0.1.2

+

+ Archive and manage your Bambu Lab 3MF files +

+

+ Connect to printers via LAN mode (developer mode required)

- -
-
-
-

Save thumbnails

-

- Extract and save preview images from 3MF files -

+ + +
+ + {/* Right Column - Smart Plugs */} +
+ + +
+
+ +

Smart Plugs

+
+
- -
- - - - - -

Cost Tracking

-
- -
- - - updateSetting('default_filament_cost', parseFloat(e.target.value) || 0) - } - 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" - /> -
-
- - -
-
-
- - - -

About

-
- -
-

Bambusy v0.1.0

-

- Archive and manage your Bambu Lab 3MF files + + +

+ Connect Tasmota-based smart plugs to automate power control for your printers.

-

- Connect to printers via LAN mode (developer mode required) -

-
-
-
+ {plugsLoading ? ( +
+ +
+ ) : smartPlugs && smartPlugs.length > 0 ? ( +
+ {smartPlugs.map((plug) => ( + { + setEditingPlug(p); + setShowPlugModal(true); + }} + /> + ))} +
+ ) : ( +
+ +

No smart plugs configured

+

Add a Tasmota plug to get started

+
+ )} + + +
- {/* Reset Confirmation Modal */} - {showResetConfirm && ( - { - resetMutation.mutate(); - setShowResetConfirm(false); + {/* Smart Plug Modal */} + {showPlugModal && ( + { + setShowPlugModal(false); + setEditingPlug(null); }} - onCancel={() => setShowResetConfirm(false)} /> )}
diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 50502fb56..0b0d934fa 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -6,6 +6,7 @@ import { XCircle, DollarSign, Printer, + Target, } from 'lucide-react'; import { api } from '../api/client'; import { PrintCalendar } from '../components/PrintCalendar'; @@ -118,6 +119,90 @@ function SuccessRateWidget({ ); } +function TimeAccuracyWidget({ + stats, + printerMap, +}: { + stats: { + average_time_accuracy: number | null; + time_accuracy_by_printer: Record | null; + } | undefined; + printerMap: Map; +}) { + const accuracy = stats?.average_time_accuracy; + + if (accuracy === null || accuracy === undefined) { + return ( +
+

No time accuracy data yet

+
+ ); + } + + // Normalize accuracy for display (100% = perfect, clamp between 50-150 for gauge) + const displayValue = Math.min(150, Math.max(50, accuracy)); + const normalizedForGauge = ((displayValue - 50) / 100) * 100; // 50-150 -> 0-100 + + // Color based on accuracy + const getColor = (acc: number) => { + if (acc >= 95 && acc <= 105) return '#00ae42'; // Green - within 5% + if (acc > 105) return '#3b82f6'; // Blue - faster than expected + return '#f97316'; // Orange - slower than expected + }; + + const color = getColor(accuracy); + const deviation = accuracy - 100; + + return ( +
+
+ + + + +
+ {accuracy.toFixed(0)}% + = 0 ? 'text-blue-400' : 'text-orange-400'}`}> + {deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}% + +
+
+
+
+ + 100% = perfect estimate +
+ {stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && ( +
+ {Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => ( +
+ + {printerMap.get(printerId) || `Printer ${printerId}`} + + = 95 && acc <= 105 ? 'text-bambu-green' : + acc > 105 ? 'text-blue-400' : 'text-orange-400' + }`}> + {acc.toFixed(0)}% + +
+ ))} +
+ )} +
+
+ ); +} + function FilamentTypesWidget({ stats, }: { @@ -253,6 +338,12 @@ export function StatsPage() { component: , defaultSize: 1, }, + { + id: 'time-accuracy', + title: 'Time Accuracy', + component: , + defaultSize: 1, + }, { id: 'filament-types', title: 'Filament Types', diff --git a/static/assets/index-B5KMHzxr.js b/static/assets/index-B5KMHzxr.js deleted file mode 100644 index 00b24e8cc..000000000 --- a/static/assets/index-B5KMHzxr.js +++ /dev/null @@ -1,8450 +0,0 @@ -function F9(t,e){for(var n=0;ni[r]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const a of r)if(a.type==="childList")for(const s of a.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&i(s)}).observe(document,{childList:!0,subtree:!0});function n(r){const a={};return r.integrity&&(a.integrity=r.integrity),r.referrerPolicy&&(a.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?a.credentials="include":r.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function i(r){if(r.ep)return;r.ep=!0;const a=n(r);fetch(r.href,a)}})();var s0=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Xs(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var bS={exports:{}},Xh={};var LD;function z9(){if(LD)return Xh;LD=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(i,r,a){var s=null;if(a!==void 0&&(s=""+a),r.key!==void 0&&(s=""+r.key),"key"in r){a={};for(var l in r)l!=="key"&&(a[l]=r[l])}else a=r;return r=a.ref,{$$typeof:t,type:i,key:s,ref:r!==void 0?r:null,props:a}}return Xh.Fragment=e,Xh.jsx=n,Xh.jsxs=n,Xh}var ID;function B9(){return ID||(ID=1,bS.exports=z9()),bS.exports}var E=B9(),_S={exports:{}},Ft={};var UD;function j9(){if(UD)return Ft;UD=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),i=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),a=Symbol.for("react.consumer"),s=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),f=Symbol.for("react.lazy"),h=Symbol.for("react.activity"),g=Symbol.iterator;function m(H){return H===null||typeof H!="object"?null:(H=g&&H[g]||H["@@iterator"],typeof H=="function"?H:null)}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},y=Object.assign,x={};function v(H,q,re){this.props=H,this.context=q,this.refs=x,this.updater=re||b}v.prototype.isReactComponent={},v.prototype.setState=function(H,q){if(typeof H!="object"&&typeof H!="function"&&H!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,H,q,"setState")},v.prototype.forceUpdate=function(H){this.updater.enqueueForceUpdate(this,H,"forceUpdate")};function w(){}w.prototype=v.prototype;function S(H,q,re){this.props=H,this.context=q,this.refs=x,this.updater=re||b}var A=S.prototype=new w;A.constructor=S,y(A,v.prototype),A.isPureReactComponent=!0;var R=Array.isArray;function N(){}var C={H:null,A:null,T:null,S:null},F=Object.prototype.hasOwnProperty;function P(H,q,re){var ce=re.ref;return{$$typeof:t,type:H,key:q,ref:ce!==void 0?ce:null,props:re}}function L(H,q){return P(H.type,q,H.props)}function B(H){return typeof H=="object"&&H!==null&&H.$$typeof===t}function U(H){var q={"=":"=0",":":"=2"};return"$"+H.replace(/[=:]/g,function(re){return q[re]})}var $=/\/+/g;function I(H,q){return typeof H=="object"&&H!==null&&H.key!=null?U(""+H.key):q.toString(36)}function W(H){switch(H.status){case"fulfilled":return H.value;case"rejected":throw H.reason;default:switch(typeof H.status=="string"?H.then(N,N):(H.status="pending",H.then(function(q){H.status==="pending"&&(H.status="fulfilled",H.value=q)},function(q){H.status==="pending"&&(H.status="rejected",H.reason=q)})),H.status){case"fulfilled":return H.value;case"rejected":throw H.reason}}throw H}function X(H,q,re,ce,ye){var me=typeof H;(me==="undefined"||me==="boolean")&&(H=null);var be=!1;if(H===null)be=!0;else switch(me){case"bigint":case"string":case"number":be=!0;break;case"object":switch(H.$$typeof){case t:case e:be=!0;break;case f:return be=H._init,X(be(H._payload),q,re,ce,ye)}}if(be)return ye=ye(H),be=ce===""?"."+I(H,0):ce,R(ye)?(re="",be!=null&&(re=be.replace($,"$&/")+"/"),X(ye,q,re,"",function(Ae){return Ae})):ye!=null&&(B(ye)&&(ye=L(ye,re+(ye.key==null||H&&H.key===ye.key?"":(""+ye.key).replace($,"$&/")+"/")+be)),q.push(ye)),1;be=0;var Ne=ce===""?".":ce+":";if(R(H))for(var he=0;he>>1,se=X[J];if(0>>1;Jr(re,Y))cer(ye,re)?(X[J]=ye,X[ce]=Y,J=ce):(X[J]=re,X[q]=Y,J=q);else if(cer(ye,Y))X[J]=ye,X[ce]=Y,J=ce;else break e}}return V}function r(X,V){var Y=X.sortIndex-V.sortIndex;return Y!==0?Y:X.id-V.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var a=performance;t.unstable_now=function(){return a.now()}}else{var s=Date,l=s.now();t.unstable_now=function(){return s.now()-l}}var c=[],d=[],f=1,h=null,g=3,m=!1,b=!1,y=!1,x=!1,v=typeof setTimeout=="function"?setTimeout:null,w=typeof clearTimeout=="function"?clearTimeout:null,S=typeof setImmediate<"u"?setImmediate:null;function A(X){for(var V=n(d);V!==null;){if(V.callback===null)i(d);else if(V.startTime<=X)i(d),V.sortIndex=V.expirationTime,e(c,V);else break;V=n(d)}}function R(X){if(y=!1,A(X),!b)if(n(c)!==null)b=!0,N||(N=!0,U());else{var V=n(d);V!==null&&W(R,V.startTime-X)}}var N=!1,C=-1,F=5,P=-1;function L(){return x?!0:!(t.unstable_now()-PX&&L());){var J=h.callback;if(typeof J=="function"){h.callback=null,g=h.priorityLevel;var se=J(h.expirationTime<=X);if(X=t.unstable_now(),typeof se=="function"){h.callback=se,A(X),V=!0;break t}h===n(c)&&i(c),A(X)}else i(c);h=n(c)}if(h!==null)V=!0;else{var H=n(d);H!==null&&W(R,H.startTime-X),V=!1}}break e}finally{h=null,g=Y,m=!1}V=void 0}}finally{V?U():N=!1}}}var U;if(typeof S=="function")U=function(){S(B)};else if(typeof MessageChannel<"u"){var $=new MessageChannel,I=$.port2;$.port1.onmessage=B,U=function(){I.postMessage(null)}}else U=function(){v(B,0)};function W(X,V){C=v(function(){X(t.unstable_now())},V)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(X){X.callback=null},t.unstable_forceFrameRate=function(X){0>X||125J?(X.sortIndex=Y,e(d,X),n(c)===null&&X===n(d)&&(y?(w(C),C=-1):y=!0,W(R,Y-J))):(X.sortIndex=se,e(c,X),b||m||(b=!0,N||(N=!0,U()))),X},t.unstable_shouldYield=L,t.unstable_wrapCallback=function(X){var V=g;return function(){var Y=g;g=V;try{return X.apply(this,arguments)}finally{g=Y}}}})(ES)),ES}var zD;function V9(){return zD||(zD=1,wS.exports=G9()),wS.exports}var MS={exports:{}},gr={};var BD;function $9(){if(BD)return gr;BD=1;var t=If();function e(c){var d="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),MS.exports=$9(),MS.exports}var HD;function W9(){if(HD)return qh;HD=1;var t=V9(),e=If(),n=Q4();function i(o){var u="https://react.dev/errors/"+o;if(1se||(o.current=J[se],J[se]=null,se--)}function re(o,u){se++,J[se]=o.current,o.current=u}var ce=H(null),ye=H(null),me=H(null),be=H(null);function Ne(o,u){switch(re(me,u),re(ye,o),re(ce,null),u.nodeType){case 9:case 11:o=(o=u.documentElement)&&(o=o.namespaceURI)?iD(o):0;break;default:if(o=u.tagName,u=u.namespaceURI)u=iD(u),o=rD(u,o);else switch(o){case"svg":o=1;break;case"math":o=2;break;default:o=0}}q(ce),re(ce,o)}function he(){q(ce),q(ye),q(me)}function Ae(o){o.memoizedState!==null&&re(be,o);var u=ce.current,p=rD(u,o.type);u!==p&&(re(ye,o),re(ce,p))}function Be(o){ye.current===o&&(q(ce),q(ye)),be.current===o&&(q(be),Gh._currentValue=Y)}var le,lt;function z(o){if(le===void 0)try{throw Error()}catch(p){var u=p.stack.trim().match(/\n( *(at )?)/);le=u&&u[1]||"",lt=-1)":-1M||Se[_]!==ze[M]){var Qe=` -`+Se[_].replace(" at new "," at ");return o.displayName&&Qe.includes("")&&(Qe=Qe.replace("",o.displayName)),Qe}while(1<=_&&0<=M);break}}}finally{G=!1,Error.prepareStackTrace=p}return(p=o?o.displayName||o.name:"")?z(p):""}function Q(o,u){switch(o.tag){case 26:case 27:case 5:return z(o.type);case 16:return z("Lazy");case 13:return o.child!==u&&u!==null?z("Suspense Fallback"):z("Suspense");case 19:return z("SuspenseList");case 0:case 15:return ne(o.type,!1);case 11:return ne(o.type.render,!1);case 1:return ne(o.type,!0);case 31:return z("Activity");default:return""}}function ee(o){try{var u="",p=null;do u+=Q(o,p),p=o,o=o.return;while(o);return u}catch(_){return` -Error generating stack: `+_.message+` -`+_.stack}}var ae=Object.prototype.hasOwnProperty,ie=t.unstable_scheduleCallback,we=t.unstable_cancelCallback,ve=t.unstable_shouldYield,k=t.unstable_requestPaint,O=t.unstable_now,K=t.unstable_getCurrentPriorityLevel,pe=t.unstable_ImmediatePriority,xe=t.unstable_UserBlockingPriority,fe=t.unstable_NormalPriority,Ke=t.unstable_LowPriority,Ie=t.unstable_IdlePriority,$e=t.log,rt=t.unstable_setDisableYieldValue,ke=null,De=null;function ct(o){if(typeof $e=="function"&&rt(o),De&&typeof De.setStrictMode=="function")try{De.setStrictMode(ke,o)}catch{}}var et=Math.clz32?Math.clz32:ue,Je=Math.log,st=Math.LN2;function ue(o){return o>>>=0,o===0?32:31-(Je(o)/st|0)|0}var tt=256,de=262144,Le=4194304;function Me(o){var u=o&42;if(u!==0)return u;switch(o&-o){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return o&261888;case 262144:case 524288:case 1048576:case 2097152:return o&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return o&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return o}}function ge(o,u,p){var _=o.pendingLanes;if(_===0)return 0;var M=0,D=o.suspendedLanes,j=o.pingedLanes;o=o.warmLanes;var te=_&134217727;return te!==0?(_=te&~D,_!==0?M=Me(_):(j&=te,j!==0?M=Me(j):p||(p=te&~o,p!==0&&(M=Me(p))))):(te=_&~D,te!==0?M=Me(te):j!==0?M=Me(j):p||(p=_&~o,p!==0&&(M=Me(p)))),M===0?0:u!==0&&u!==M&&(u&D)===0&&(D=M&-M,p=u&-u,D>=p||D===32&&(p&4194048)!==0)?u:M}function je(o,u){return(o.pendingLanes&~(o.suspendedLanes&~o.pingedLanes)&u)===0}function at(o,u){switch(o){case 1:case 2:case 4:case 8:case 64:return u+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return u+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function mt(){var o=Le;return Le<<=1,(Le&62914560)===0&&(Le=4194304),o}function wt(o){for(var u=[],p=0;31>p;p++)u.push(o);return u}function Wt(o,u){o.pendingLanes|=u,u!==268435456&&(o.suspendedLanes=0,o.pingedLanes=0,o.warmLanes=0)}function Ct(o,u,p,_,M,D){var j=o.pendingLanes;o.pendingLanes=p,o.suspendedLanes=0,o.pingedLanes=0,o.warmLanes=0,o.expiredLanes&=p,o.entangledLanes&=p,o.errorRecoveryDisabledLanes&=p,o.shellSuspendCounter=0;var te=o.entanglements,Se=o.expirationTimes,ze=o.hiddenUpdates;for(p=j&~p;0"u")return null;try{return o.activeElement||o.body}catch{return o.body}}var ti=/[\n"\\]/g;function pn(o){return o.replace(ti,function(u){return"\\"+u.charCodeAt(0).toString(16)+" "})}function es(o,u,p,_,M,D,j,te){o.name="",j!=null&&typeof j!="function"&&typeof j!="symbol"&&typeof j!="boolean"?o.type=j:o.removeAttribute("type"),u!=null?j==="number"?(u===0&&o.value===""||o.value!=u)&&(o.value=""+qt(u)):o.value!==""+qt(u)&&(o.value=""+qt(u)):j!=="submit"&&j!=="reset"||o.removeAttribute("value"),u!=null?ji(o,j,qt(u)):p!=null?ji(o,j,qt(p)):_!=null&&o.removeAttribute("value"),M==null&&D!=null&&(o.defaultChecked=!!D),M!=null&&(o.checked=M&&typeof M!="function"&&typeof M!="symbol"),te!=null&&typeof te!="function"&&typeof te!="symbol"&&typeof te!="boolean"?o.name=""+qt(te):o.removeAttribute("name")}function gn(o,u,p,_,M,D,j,te){if(D!=null&&typeof D!="function"&&typeof D!="symbol"&&typeof D!="boolean"&&(o.type=D),u!=null||p!=null){if(!(D!=="submit"&&D!=="reset"||u!=null)){kt(o);return}p=p!=null?""+qt(p):"",u=u!=null?""+qt(u):p,te||u===o.value||(o.value=u),o.defaultValue=u}_=_??M,_=typeof _!="function"&&typeof _!="symbol"&&!!_,o.checked=te?o.checked:!!_,o.defaultChecked=!!_,j!=null&&typeof j!="function"&&typeof j!="symbol"&&typeof j!="boolean"&&(o.name=j),kt(o)}function ji(o,u,p){u==="number"&&Kn(o.ownerDocument)===o||o.defaultValue===""+p||(o.defaultValue=""+p)}function Ei(o,u,p,_){if(o=o.options,u){u={};for(var M=0;M"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),bb=!1;if(Js)try{var oh={};Object.defineProperty(oh,"passive",{get:function(){bb=!0}}),window.addEventListener("test",oh,oh),window.removeEventListener("test",oh,oh)}catch{bb=!1}var ll=null,_b=null,Qm=null;function r3(){if(Qm)return Qm;var o,u=_b,p=u.length,_,M="value"in ll?ll.value:ll.textContent,D=M.length;for(o=0;o=uh),u3=" ",d3=!1;function f3(o,u){switch(o){case"keyup":return sV.indexOf(u.keyCode)!==-1;case"keydown":return u.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function h3(o){return o=o.detail,typeof o=="object"&&"data"in o?o.data:null}var ju=!1;function lV(o,u){switch(o){case"compositionend":return h3(u);case"keypress":return u.which!==32?null:(d3=!0,u3);case"textInput":return o=u.data,o===u3&&d3?null:o;default:return null}}function cV(o,u){if(ju)return o==="compositionend"||!Ab&&f3(o,u)?(o=r3(),Qm=_b=ll=null,ju=!1,o):null;switch(o){case"paste":return null;case"keypress":if(!(u.ctrlKey||u.altKey||u.metaKey)||u.ctrlKey&&u.altKey){if(u.char&&1=u)return{node:p,offset:u-o};o=_}e:{for(;p;){if(p.nextSibling){p=p.nextSibling;break e}p=p.parentNode}p=void 0}p=_3(p)}}function w3(o,u){return o&&u?o===u?!0:o&&o.nodeType===3?!1:u&&u.nodeType===3?w3(o,u.parentNode):"contains"in o?o.contains(u):o.compareDocumentPosition?!!(o.compareDocumentPosition(u)&16):!1:!1}function E3(o){o=o!=null&&o.ownerDocument!=null&&o.ownerDocument.defaultView!=null?o.ownerDocument.defaultView:window;for(var u=Kn(o.document);u instanceof o.HTMLIFrameElement;){try{var p=typeof u.contentWindow.location.href=="string"}catch{p=!1}if(p)o=u.contentWindow;else break;u=Kn(o.document)}return u}function Rb(o){var u=o&&o.nodeName&&o.nodeName.toLowerCase();return u&&(u==="input"&&(o.type==="text"||o.type==="search"||o.type==="tel"||o.type==="url"||o.type==="password")||u==="textarea"||o.contentEditable==="true")}var vV=Js&&"documentMode"in document&&11>=document.documentMode,Hu=null,Pb=null,ph=null,Db=!1;function M3(o,u,p){var _=p.window===p?p.document:p.nodeType===9?p:p.ownerDocument;Db||Hu==null||Hu!==Kn(_)||(_=Hu,"selectionStart"in _&&Rb(_)?_={start:_.selectionStart,end:_.selectionEnd}:(_=(_.ownerDocument&&_.ownerDocument.defaultView||window).getSelection(),_={anchorNode:_.anchorNode,anchorOffset:_.anchorOffset,focusNode:_.focusNode,focusOffset:_.focusOffset}),ph&&hh(ph,_)||(ph=_,_=$g(Pb,"onSelect"),0<_.length&&(u=new tg("onSelect","select",null,u,p),o.push({event:u,listeners:_}),u.target=Hu)))}function bc(o,u){var p={};return p[o.toLowerCase()]=u.toLowerCase(),p["Webkit"+o]="webkit"+u,p["Moz"+o]="moz"+u,p}var Gu={animationend:bc("Animation","AnimationEnd"),animationiteration:bc("Animation","AnimationIteration"),animationstart:bc("Animation","AnimationStart"),transitionrun:bc("Transition","TransitionRun"),transitionstart:bc("Transition","TransitionStart"),transitioncancel:bc("Transition","TransitionCancel"),transitionend:bc("Transition","TransitionEnd")},Nb={},A3={};Js&&(A3=document.createElement("div").style,"AnimationEvent"in window||(delete Gu.animationend.animation,delete Gu.animationiteration.animation,delete Gu.animationstart.animation),"TransitionEvent"in window||delete Gu.transitionend.transition);function _c(o){if(Nb[o])return Nb[o];if(!Gu[o])return o;var u=Gu[o],p;for(p in u)if(u.hasOwnProperty(p)&&p in A3)return Nb[o]=u[p];return o}var T3=_c("animationend"),C3=_c("animationiteration"),R3=_c("animationstart"),xV=_c("transitionrun"),yV=_c("transitionstart"),bV=_c("transitioncancel"),P3=_c("transitionend"),D3=new Map,Ob="abort auxClick beforeToggle cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" ");Ob.push("scrollEnd");function ts(o,u){D3.set(o,u),ot(u,[o])}var rg=typeof reportError=="function"?reportError:function(o){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var u=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof o=="object"&&o!==null&&typeof o.message=="string"?String(o.message):String(o),error:o});if(!window.dispatchEvent(u))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",o);return}console.error(o)},Ea=[],Vu=0,Lb=0;function ag(){for(var o=Vu,u=Lb=Vu=0;u>=j,M-=j,Ms=1<<32-et(u)+M|p<jt?(tn=Tt,Tt=null):tn=Tt.sibling;var ln=Ge(Oe,Tt,Fe[jt],nt);if(ln===null){Tt===null&&(Tt=tn);break}o&&Tt&&ln.alternate===null&&u(Oe,Tt),Te=D(ln,Te,jt),on===null?Dt=ln:on.sibling=ln,on=ln,Tt=tn}if(jt===Fe.length)return p(Oe,Tt),rn&&to(Oe,jt),Dt;if(Tt===null){for(;jtjt?(tn=Tt,Tt=null):tn=Tt.sibling;var Pl=Ge(Oe,Tt,ln.value,nt);if(Pl===null){Tt===null&&(Tt=tn);break}o&&Tt&&Pl.alternate===null&&u(Oe,Tt),Te=D(Pl,Te,jt),on===null?Dt=Pl:on.sibling=Pl,on=Pl,Tt=tn}if(ln.done)return p(Oe,Tt),rn&&to(Oe,jt),Dt;if(Tt===null){for(;!ln.done;jt++,ln=Fe.next())ln=it(Oe,ln.value,nt),ln!==null&&(Te=D(ln,Te,jt),on===null?Dt=ln:on.sibling=ln,on=ln);return rn&&to(Oe,jt),Dt}for(Tt=_(Tt);!ln.done;jt++,ln=Fe.next())ln=Ye(Tt,Oe,jt,ln.value,nt),ln!==null&&(o&&ln.alternate!==null&&Tt.delete(ln.key===null?jt:ln.key),Te=D(ln,Te,jt),on===null?Dt=ln:on.sibling=ln,on=ln);return o&&Tt.forEach(function(k9){return u(Oe,k9)}),rn&&to(Oe,jt),Dt}function Sn(Oe,Te,Fe,nt){if(typeof Fe=="object"&&Fe!==null&&Fe.type===y&&Fe.key===null&&(Fe=Fe.props.children),typeof Fe=="object"&&Fe!==null){switch(Fe.$$typeof){case m:e:{for(var Dt=Fe.key;Te!==null;){if(Te.key===Dt){if(Dt=Fe.type,Dt===y){if(Te.tag===7){p(Oe,Te.sibling),nt=M(Te,Fe.props.children),nt.return=Oe,Oe=nt;break e}}else if(Te.elementType===Dt||typeof Dt=="object"&&Dt!==null&&Dt.$$typeof===F&&Cc(Dt)===Te.type){p(Oe,Te.sibling),nt=M(Te,Fe.props),bh(nt,Fe),nt.return=Oe,Oe=nt;break e}p(Oe,Te);break}else u(Oe,Te);Te=Te.sibling}Fe.type===y?(nt=wc(Fe.props.children,Oe.mode,nt,Fe.key),nt.return=Oe,Oe=nt):(nt=lg(Fe.type,Fe.key,Fe.props,null,Oe.mode,nt),bh(nt,Fe),nt.return=Oe,Oe=nt)}return j(Oe);case b:e:{for(Dt=Fe.key;Te!==null;){if(Te.key===Dt)if(Te.tag===4&&Te.stateNode.containerInfo===Fe.containerInfo&&Te.stateNode.implementation===Fe.implementation){p(Oe,Te.sibling),nt=M(Te,Fe.children||[]),nt.return=Oe,Oe=nt;break e}else{p(Oe,Te);break}else u(Oe,Te);Te=Te.sibling}nt=Fb(Fe,Oe.mode,nt),nt.return=Oe,Oe=nt}return j(Oe);case F:return Fe=Cc(Fe),Sn(Oe,Te,Fe,nt)}if(W(Fe))return St(Oe,Te,Fe,nt);if(U(Fe)){if(Dt=U(Fe),typeof Dt!="function")throw Error(i(150));return Fe=Dt.call(Fe),Ot(Oe,Te,Fe,nt)}if(typeof Fe.then=="function")return Sn(Oe,Te,mg(Fe),nt);if(Fe.$$typeof===S)return Sn(Oe,Te,dg(Oe,Fe),nt);gg(Oe,Fe)}return typeof Fe=="string"&&Fe!==""||typeof Fe=="number"||typeof Fe=="bigint"?(Fe=""+Fe,Te!==null&&Te.tag===6?(p(Oe,Te.sibling),nt=M(Te,Fe),nt.return=Oe,Oe=nt):(p(Oe,Te),nt=kb(Fe,Oe.mode,nt),nt.return=Oe,Oe=nt),j(Oe)):p(Oe,Te)}return function(Oe,Te,Fe,nt){try{yh=0;var Dt=Sn(Oe,Te,Fe,nt);return Ju=null,Dt}catch(Tt){if(Tt===Qu||Tt===hg)throw Tt;var on=ra(29,Tt,null,Oe.mode);return on.lanes=nt,on.return=Oe,on}finally{}}}var Pc=q3(!0),Y3=q3(!1),hl=!1;function Kb(o){o.updateQueue={baseState:o.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Zb(o,u){o=o.updateQueue,u.updateQueue===o&&(u.updateQueue={baseState:o.baseState,firstBaseUpdate:o.firstBaseUpdate,lastBaseUpdate:o.lastBaseUpdate,shared:o.shared,callbacks:null})}function pl(o){return{lane:o,tag:0,payload:null,callback:null,next:null}}function ml(o,u,p){var _=o.updateQueue;if(_===null)return null;if(_=_.shared,(hn&2)!==0){var M=_.pending;return M===null?u.next=u:(u.next=M.next,M.next=u),_.pending=u,u=og(o),N3(o,null,p),u}return sg(o,_,u,p),og(o)}function _h(o,u,p){if(u=u.updateQueue,u!==null&&(u=u.shared,(p&4194048)!==0)){var _=u.lanes;_&=o.pendingLanes,p|=_,u.lanes=p,Rn(o,p)}}function Qb(o,u){var p=o.updateQueue,_=o.alternate;if(_!==null&&(_=_.updateQueue,p===_)){var M=null,D=null;if(p=p.firstBaseUpdate,p!==null){do{var j={lane:p.lane,tag:p.tag,payload:p.payload,callback:null,next:null};D===null?M=D=j:D=D.next=j,p=p.next}while(p!==null);D===null?M=D=u:D=D.next=u}else M=D=u;p={baseState:_.baseState,firstBaseUpdate:M,lastBaseUpdate:D,shared:_.shared,callbacks:_.callbacks},o.updateQueue=p;return}o=p.lastBaseUpdate,o===null?p.firstBaseUpdate=u:o.next=u,p.lastBaseUpdate=u}var Jb=!1;function Sh(){if(Jb){var o=Zu;if(o!==null)throw o}}function wh(o,u,p,_){Jb=!1;var M=o.updateQueue;hl=!1;var D=M.firstBaseUpdate,j=M.lastBaseUpdate,te=M.shared.pending;if(te!==null){M.shared.pending=null;var Se=te,ze=Se.next;Se.next=null,j===null?D=ze:j.next=ze,j=Se;var Qe=o.alternate;Qe!==null&&(Qe=Qe.updateQueue,te=Qe.lastBaseUpdate,te!==j&&(te===null?Qe.firstBaseUpdate=ze:te.next=ze,Qe.lastBaseUpdate=Se))}if(D!==null){var it=M.baseState;j=0,Qe=ze=Se=null,te=D;do{var Ge=te.lane&-536870913,Ye=Ge!==te.lane;if(Ye?(en&Ge)===Ge:(_&Ge)===Ge){Ge!==0&&Ge===Ku&&(Jb=!0),Qe!==null&&(Qe=Qe.next={lane:0,tag:te.tag,payload:te.payload,callback:null,next:null});e:{var St=o,Ot=te;Ge=u;var Sn=p;switch(Ot.tag){case 1:if(St=Ot.payload,typeof St=="function"){it=St.call(Sn,it,Ge);break e}it=St;break e;case 3:St.flags=St.flags&-65537|128;case 0:if(St=Ot.payload,Ge=typeof St=="function"?St.call(Sn,it,Ge):St,Ge==null)break e;it=h({},it,Ge);break e;case 2:hl=!0}}Ge=te.callback,Ge!==null&&(o.flags|=64,Ye&&(o.flags|=8192),Ye=M.callbacks,Ye===null?M.callbacks=[Ge]:Ye.push(Ge))}else Ye={lane:Ge,tag:te.tag,payload:te.payload,callback:te.callback,next:null},Qe===null?(ze=Qe=Ye,Se=it):Qe=Qe.next=Ye,j|=Ge;if(te=te.next,te===null){if(te=M.shared.pending,te===null)break;Ye=te,te=Ye.next,Ye.next=null,M.lastBaseUpdate=Ye,M.shared.pending=null}}while(!0);Qe===null&&(Se=it),M.baseState=Se,M.firstBaseUpdate=ze,M.lastBaseUpdate=Qe,D===null&&(M.shared.lanes=0),bl|=j,o.lanes=j,o.memoizedState=it}}function K3(o,u){if(typeof o!="function")throw Error(i(191,o));o.call(u)}function Z3(o,u){var p=o.callbacks;if(p!==null)for(o.callbacks=null,o=0;oD?D:8;var j=X.T,te={};X.T=te,x_(o,!1,u,p);try{var Se=M(),ze=X.S;if(ze!==null&&ze(te,Se),Se!==null&&typeof Se=="object"&&typeof Se.then=="function"){var Qe=AV(Se,_);Ah(o,u,Qe,ca(o))}else Ah(o,u,_,ca(o))}catch(it){Ah(o,u,{then:function(){},status:"rejected",reason:it},ca())}finally{V.p=D,j!==null&&te.types!==null&&(j.types=te.types),X.T=j}}function NV(){}function g_(o,u,p,_){if(o.tag!==5)throw Error(i(476));var M=RR(o).queue;CR(o,M,u,Y,p===null?NV:function(){return PR(o),p(_)})}function RR(o){var u=o.memoizedState;if(u!==null)return u;u={memoizedState:Y,baseState:Y,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ao,lastRenderedState:Y},next:null};var p={};return u.next={memoizedState:p,baseState:p,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ao,lastRenderedState:p},next:null},o.memoizedState=u,o=o.alternate,o!==null&&(o.memoizedState=u),u}function PR(o){var u=RR(o);u.next===null&&(u=o.alternate.memoizedState),Ah(o,u.next.queue,{},ca())}function v_(){return rr(Gh)}function DR(){return ii().memoizedState}function NR(){return ii().memoizedState}function OV(o){for(var u=o.return;u!==null;){switch(u.tag){case 24:case 3:var p=ca();o=pl(p);var _=ml(u,o,p);_!==null&&(Vr(_,u,p),_h(_,u,p)),u={cache:Wb()},o.payload=u;return}u=u.return}}function LV(o,u,p){var _=ca();p={lane:_,revertLane:0,gesture:null,action:p,hasEagerState:!1,eagerState:null,next:null},Ag(o)?LR(u,p):(p=Ib(o,u,p,_),p!==null&&(Vr(p,o,_),IR(p,u,_)))}function OR(o,u,p){var _=ca();Ah(o,u,p,_)}function Ah(o,u,p,_){var M={lane:_,revertLane:0,gesture:null,action:p,hasEagerState:!1,eagerState:null,next:null};if(Ag(o))LR(u,M);else{var D=o.alternate;if(o.lanes===0&&(D===null||D.lanes===0)&&(D=u.lastRenderedReducer,D!==null))try{var j=u.lastRenderedState,te=D(j,p);if(M.hasEagerState=!0,M.eagerState=te,ia(te,j))return sg(o,u,M,0),Pn===null&&ag(),!1}catch{}finally{}if(p=Ib(o,u,M,_),p!==null)return Vr(p,o,_),IR(p,u,_),!0}return!1}function x_(o,u,p,_){if(_={lane:2,revertLane:K_(),gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},Ag(o)){if(u)throw Error(i(479))}else u=Ib(o,p,_,2),u!==null&&Vr(u,o,2)}function Ag(o){var u=o.alternate;return o===Bt||u!==null&&u===Bt}function LR(o,u){td=yg=!0;var p=o.pending;p===null?u.next=u:(u.next=p.next,p.next=u),o.pending=u}function IR(o,u,p){if((p&4194048)!==0){var _=u.lanes;_&=o.pendingLanes,p|=_,u.lanes=p,Rn(o,p)}}var Th={readContext:rr,use:Sg,useCallback:Zn,useContext:Zn,useEffect:Zn,useImperativeHandle:Zn,useLayoutEffect:Zn,useInsertionEffect:Zn,useMemo:Zn,useReducer:Zn,useRef:Zn,useState:Zn,useDebugValue:Zn,useDeferredValue:Zn,useTransition:Zn,useSyncExternalStore:Zn,useId:Zn,useHostTransitionStatus:Zn,useFormState:Zn,useActionState:Zn,useOptimistic:Zn,useMemoCache:Zn,useCacheRefresh:Zn};Th.useEffectEvent=Zn;var UR={readContext:rr,use:Sg,useCallback:function(o,u){return Tr().memoizedState=[o,u===void 0?null:u],o},useContext:rr,useEffect:yR,useImperativeHandle:function(o,u,p){p=p!=null?p.concat([o]):null,Eg(4194308,4,wR.bind(null,u,o),p)},useLayoutEffect:function(o,u){return Eg(4194308,4,o,u)},useInsertionEffect:function(o,u){Eg(4,2,o,u)},useMemo:function(o,u){var p=Tr();u=u===void 0?null:u;var _=o();if(Dc){ct(!0);try{o()}finally{ct(!1)}}return p.memoizedState=[_,u],_},useReducer:function(o,u,p){var _=Tr();if(p!==void 0){var M=p(u);if(Dc){ct(!0);try{p(u)}finally{ct(!1)}}}else M=u;return _.memoizedState=_.baseState=M,o={pending:null,lanes:0,dispatch:null,lastRenderedReducer:o,lastRenderedState:M},_.queue=o,o=o.dispatch=LV.bind(null,Bt,o),[_.memoizedState,o]},useRef:function(o){var u=Tr();return o={current:o},u.memoizedState=o},useState:function(o){o=d_(o);var u=o.queue,p=OR.bind(null,Bt,u);return u.dispatch=p,[o.memoizedState,p]},useDebugValue:p_,useDeferredValue:function(o,u){var p=Tr();return m_(p,o,u)},useTransition:function(){var o=d_(!1);return o=CR.bind(null,Bt,o.queue,!0,!1),Tr().memoizedState=o,[!1,o]},useSyncExternalStore:function(o,u,p){var _=Bt,M=Tr();if(rn){if(p===void 0)throw Error(i(407));p=p()}else{if(p=u(),Pn===null)throw Error(i(349));(en&127)!==0||iR(_,u,p)}M.memoizedState=p;var D={value:p,getSnapshot:u};return M.queue=D,yR(aR.bind(null,_,D,o),[o]),_.flags|=2048,id(9,{destroy:void 0},rR.bind(null,_,D,p,u),null),p},useId:function(){var o=Tr(),u=Pn.identifierPrefix;if(rn){var p=As,_=Ms;p=(_&~(1<<32-et(_)-1)).toString(32)+p,u="_"+u+"R_"+p,p=bg++,0<\/script>",D=D.removeChild(D.firstChild);break;case"select":D=typeof _.is=="string"?j.createElement("select",{is:_.is}):j.createElement("select"),_.multiple?D.multiple=!0:_.size&&(D.size=_.size);break;default:D=typeof _.is=="string"?j.createElement(M,{is:_.is}):j.createElement(M)}}D[Wn]=u,D[ei]=_;e:for(j=u.child;j!==null;){if(j.tag===5||j.tag===6)D.appendChild(j.stateNode);else if(j.tag!==4&&j.tag!==27&&j.child!==null){j.child.return=j,j=j.child;continue}if(j===u)break e;for(;j.sibling===null;){if(j.return===null||j.return===u)break e;j=j.return}j.sibling.return=j.return,j=j.sibling}u.stateNode=D;e:switch(sr(D,M,_),M){case"button":case"input":case"select":case"textarea":_=!!_.autoFocus;break e;case"img":_=!0;break e;default:_=!1}_&&oo(u)}}return kn(u),N_(u,u.type,o===null?null:o.memoizedProps,u.pendingProps,p),null;case 6:if(o&&u.stateNode!=null)o.memoizedProps!==_&&oo(u);else{if(typeof _!="string"&&u.stateNode===null)throw Error(i(166));if(o=me.current,qu(u)){if(o=u.stateNode,p=u.memoizedProps,_=null,M=ir,M!==null)switch(M.tag){case 27:case 5:_=M.memoizedProps}o[Wn]=u,o=!!(o.nodeValue===p||_!==null&&_.suppressHydrationWarning===!0||tD(o.nodeValue,p)),o||dl(u,!0)}else o=Wg(o).createTextNode(_),o[Wn]=u,u.stateNode=o}return kn(u),null;case 31:if(p=u.memoizedState,o===null||o.memoizedState!==null){if(_=qu(u),p!==null){if(o===null){if(!_)throw Error(i(318));if(o=u.memoizedState,o=o!==null?o.dehydrated:null,!o)throw Error(i(557));o[Wn]=u}else Ec(),(u.flags&128)===0&&(u.memoizedState=null),u.flags|=4;kn(u),o=!1}else p=Hb(),o!==null&&o.memoizedState!==null&&(o.memoizedState.hydrationErrors=p),o=!0;if(!o)return u.flags&256?(sa(u),u):(sa(u),null);if((u.flags&128)!==0)throw Error(i(558))}return kn(u),null;case 13:if(_=u.memoizedState,o===null||o.memoizedState!==null&&o.memoizedState.dehydrated!==null){if(M=qu(u),_!==null&&_.dehydrated!==null){if(o===null){if(!M)throw Error(i(318));if(M=u.memoizedState,M=M!==null?M.dehydrated:null,!M)throw Error(i(317));M[Wn]=u}else Ec(),(u.flags&128)===0&&(u.memoizedState=null),u.flags|=4;kn(u),M=!1}else M=Hb(),o!==null&&o.memoizedState!==null&&(o.memoizedState.hydrationErrors=M),M=!0;if(!M)return u.flags&256?(sa(u),u):(sa(u),null)}return sa(u),(u.flags&128)!==0?(u.lanes=p,u):(p=_!==null,o=o!==null&&o.memoizedState!==null,p&&(_=u.child,M=null,_.alternate!==null&&_.alternate.memoizedState!==null&&_.alternate.memoizedState.cachePool!==null&&(M=_.alternate.memoizedState.cachePool.pool),D=null,_.memoizedState!==null&&_.memoizedState.cachePool!==null&&(D=_.memoizedState.cachePool.pool),D!==M&&(_.flags|=2048)),p!==o&&p&&(u.child.flags|=8192),Dg(u,u.updateQueue),kn(u),null);case 4:return he(),o===null&&eS(u.stateNode.containerInfo),kn(u),null;case 10:return io(u.type),kn(u),null;case 19:if(q(ni),_=u.memoizedState,_===null)return kn(u),null;if(M=(u.flags&128)!==0,D=_.rendering,D===null)if(M)Rh(_,!1);else{if(Qn!==0||o!==null&&(o.flags&128)!==0)for(o=u.child;o!==null;){if(D=xg(o),D!==null){for(u.flags|=128,Rh(_,!1),o=D.updateQueue,u.updateQueue=o,Dg(u,o),u.subtreeFlags=0,o=p,p=u.child;p!==null;)O3(p,o),p=p.sibling;return re(ni,ni.current&1|2),rn&&to(u,_.treeForkCount),u.child}o=o.sibling}_.tail!==null&&O()>Ug&&(u.flags|=128,M=!0,Rh(_,!1),u.lanes=4194304)}else{if(!M)if(o=xg(D),o!==null){if(u.flags|=128,M=!0,o=o.updateQueue,u.updateQueue=o,Dg(u,o),Rh(_,!0),_.tail===null&&_.tailMode==="hidden"&&!D.alternate&&!rn)return kn(u),null}else 2*O()-_.renderingStartTime>Ug&&p!==536870912&&(u.flags|=128,M=!0,Rh(_,!1),u.lanes=4194304);_.isBackwards?(D.sibling=u.child,u.child=D):(o=_.last,o!==null?o.sibling=D:u.child=D,_.last=D)}return _.tail!==null?(o=_.tail,_.rendering=o,_.tail=o.sibling,_.renderingStartTime=O(),o.sibling=null,p=ni.current,re(ni,M?p&1|2:p&1),rn&&to(u,_.treeForkCount),o):(kn(u),null);case 22:case 23:return sa(u),t_(),_=u.memoizedState!==null,o!==null?o.memoizedState!==null!==_&&(u.flags|=8192):_&&(u.flags|=8192),_?(p&536870912)!==0&&(u.flags&128)===0&&(kn(u),u.subtreeFlags&6&&(u.flags|=8192)):kn(u),p=u.updateQueue,p!==null&&Dg(u,p.retryQueue),p=null,o!==null&&o.memoizedState!==null&&o.memoizedState.cachePool!==null&&(p=o.memoizedState.cachePool.pool),_=null,u.memoizedState!==null&&u.memoizedState.cachePool!==null&&(_=u.memoizedState.cachePool.pool),_!==p&&(u.flags|=2048),o!==null&&q(Tc),null;case 24:return p=null,o!==null&&(p=o.memoizedState.cache),u.memoizedState.cache!==p&&(u.flags|=2048),io(ci),kn(u),null;case 25:return null;case 30:return null}throw Error(i(156,u.tag))}function zV(o,u){switch(Bb(u),u.tag){case 1:return o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 3:return io(ci),he(),o=u.flags,(o&65536)!==0&&(o&128)===0?(u.flags=o&-65537|128,u):null;case 26:case 27:case 5:return Be(u),null;case 31:if(u.memoizedState!==null){if(sa(u),u.alternate===null)throw Error(i(340));Ec()}return o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 13:if(sa(u),o=u.memoizedState,o!==null&&o.dehydrated!==null){if(u.alternate===null)throw Error(i(340));Ec()}return o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 19:return q(ni),null;case 4:return he(),null;case 10:return io(u.type),null;case 22:case 23:return sa(u),t_(),o!==null&&q(Tc),o=u.flags,o&65536?(u.flags=o&-65537|128,u):null;case 24:return io(ci),null;case 25:return null;default:return null}}function sP(o,u){switch(Bb(u),u.tag){case 3:io(ci),he();break;case 26:case 27:case 5:Be(u);break;case 4:he();break;case 31:u.memoizedState!==null&&sa(u);break;case 13:sa(u);break;case 19:q(ni);break;case 10:io(u.type);break;case 22:case 23:sa(u),t_(),o!==null&&q(Tc);break;case 24:io(ci)}}function Ph(o,u){try{var p=u.updateQueue,_=p!==null?p.lastEffect:null;if(_!==null){var M=_.next;p=M;do{if((p.tag&o)===o){_=void 0;var D=p.create,j=p.inst;_=D(),j.destroy=_}p=p.next}while(p!==M)}}catch(te){xn(u,u.return,te)}}function xl(o,u,p){try{var _=u.updateQueue,M=_!==null?_.lastEffect:null;if(M!==null){var D=M.next;_=D;do{if((_.tag&o)===o){var j=_.inst,te=j.destroy;if(te!==void 0){j.destroy=void 0,M=u;var Se=p,ze=te;try{ze()}catch(Qe){xn(M,Se,Qe)}}}_=_.next}while(_!==D)}}catch(Qe){xn(u,u.return,Qe)}}function oP(o){var u=o.updateQueue;if(u!==null){var p=o.stateNode;try{Z3(u,p)}catch(_){xn(o,o.return,_)}}}function lP(o,u,p){p.props=Nc(o.type,o.memoizedProps),p.state=o.memoizedState;try{p.componentWillUnmount()}catch(_){xn(o,u,_)}}function Dh(o,u){try{var p=o.ref;if(p!==null){switch(o.tag){case 26:case 27:case 5:var _=o.stateNode;break;case 30:_=o.stateNode;break;default:_=o.stateNode}typeof p=="function"?o.refCleanup=p(_):p.current=_}}catch(M){xn(o,u,M)}}function Ts(o,u){var p=o.ref,_=o.refCleanup;if(p!==null)if(typeof _=="function")try{_()}catch(M){xn(o,u,M)}finally{o.refCleanup=null,o=o.alternate,o!=null&&(o.refCleanup=null)}else if(typeof p=="function")try{p(null)}catch(M){xn(o,u,M)}else p.current=null}function cP(o){var u=o.type,p=o.memoizedProps,_=o.stateNode;try{e:switch(u){case"button":case"input":case"select":case"textarea":p.autoFocus&&_.focus();break e;case"img":p.src?_.src=p.src:p.srcSet&&(_.srcset=p.srcSet)}}catch(M){xn(o,o.return,M)}}function O_(o,u,p){try{var _=o.stateNode;o9(_,o.type,p,u),_[ei]=u}catch(M){xn(o,o.return,M)}}function uP(o){return o.tag===5||o.tag===3||o.tag===26||o.tag===27&&Ml(o.type)||o.tag===4}function L_(o){e:for(;;){for(;o.sibling===null;){if(o.return===null||uP(o.return))return null;o=o.return}for(o.sibling.return=o.return,o=o.sibling;o.tag!==5&&o.tag!==6&&o.tag!==18;){if(o.tag===27&&Ml(o.type)||o.flags&2||o.child===null||o.tag===4)continue e;o.child.return=o,o=o.child}if(!(o.flags&2))return o.stateNode}}function I_(o,u,p){var _=o.tag;if(_===5||_===6)o=o.stateNode,u?(p.nodeType===9?p.body:p.nodeName==="HTML"?p.ownerDocument.body:p).insertBefore(o,u):(u=p.nodeType===9?p.body:p.nodeName==="HTML"?p.ownerDocument.body:p,u.appendChild(o),p=p._reactRootContainer,p!=null||u.onclick!==null||(u.onclick=Qs));else if(_!==4&&(_===27&&Ml(o.type)&&(p=o.stateNode,u=null),o=o.child,o!==null))for(I_(o,u,p),o=o.sibling;o!==null;)I_(o,u,p),o=o.sibling}function Ng(o,u,p){var _=o.tag;if(_===5||_===6)o=o.stateNode,u?p.insertBefore(o,u):p.appendChild(o);else if(_!==4&&(_===27&&Ml(o.type)&&(p=o.stateNode),o=o.child,o!==null))for(Ng(o,u,p),o=o.sibling;o!==null;)Ng(o,u,p),o=o.sibling}function dP(o){var u=o.stateNode,p=o.memoizedProps;try{for(var _=o.type,M=u.attributes;M.length;)u.removeAttributeNode(M[0]);sr(u,_,p),u[Wn]=o,u[ei]=p}catch(D){xn(o,o.return,D)}}var lo=!1,fi=!1,U_=!1,fP=typeof WeakSet=="function"?WeakSet:Set,Gi=null;function BV(o,u){if(o=o.containerInfo,iS=Jg,o=E3(o),Rb(o)){if("selectionStart"in o)var p={start:o.selectionStart,end:o.selectionEnd};else e:{p=(p=o.ownerDocument)&&p.defaultView||window;var _=p.getSelection&&p.getSelection();if(_&&_.rangeCount!==0){p=_.anchorNode;var M=_.anchorOffset,D=_.focusNode;_=_.focusOffset;try{p.nodeType,D.nodeType}catch{p=null;break e}var j=0,te=-1,Se=-1,ze=0,Qe=0,it=o,Ge=null;t:for(;;){for(var Ye;it!==p||M!==0&&it.nodeType!==3||(te=j+M),it!==D||_!==0&&it.nodeType!==3||(Se=j+_),it.nodeType===3&&(j+=it.nodeValue.length),(Ye=it.firstChild)!==null;)Ge=it,it=Ye;for(;;){if(it===o)break t;if(Ge===p&&++ze===M&&(te=j),Ge===D&&++Qe===_&&(Se=j),(Ye=it.nextSibling)!==null)break;it=Ge,Ge=it.parentNode}it=Ye}p=te===-1||Se===-1?null:{start:te,end:Se}}else p=null}p=p||{start:0,end:0}}else p=null;for(rS={focusedElem:o,selectionRange:p},Jg=!1,Gi=u;Gi!==null;)if(u=Gi,o=u.child,(u.subtreeFlags&1028)!==0&&o!==null)o.return=u,Gi=o;else for(;Gi!==null;){switch(u=Gi,D=u.alternate,o=u.flags,u.tag){case 0:if((o&4)!==0&&(o=u.updateQueue,o=o!==null?o.events:null,o!==null))for(p=0;p title"))),sr(D,_,p),D[Wn]=o,Re(D),_=D;break e;case"link":var j=xD("link","href",M).get(_+(p.href||""));if(j){for(var te=0;teSn&&(j=Sn,Sn=Ot,Ot=j);var Oe=S3(te,Ot),Te=S3(te,Sn);if(Oe&&Te&&(Ye.rangeCount!==1||Ye.anchorNode!==Oe.node||Ye.anchorOffset!==Oe.offset||Ye.focusNode!==Te.node||Ye.focusOffset!==Te.offset)){var Fe=it.createRange();Fe.setStart(Oe.node,Oe.offset),Ye.removeAllRanges(),Ot>Sn?(Ye.addRange(Fe),Ye.extend(Te.node,Te.offset)):(Fe.setEnd(Te.node,Te.offset),Ye.addRange(Fe))}}}}for(it=[],Ye=te;Ye=Ye.parentNode;)Ye.nodeType===1&&it.push({element:Ye,left:Ye.scrollLeft,top:Ye.scrollTop});for(typeof te.focus=="function"&&te.focus(),te=0;tep?32:p,X.T=null,p=G_,G_=null;var D=Sl,j=po;if(Mi=0,ld=Sl=null,po=0,(hn&6)!==0)throw Error(i(331));var te=hn;if(hn|=4,wP(D.current),bP(D,D.current,j,p),hn=te,kh(0,!1),De&&typeof De.onPostCommitFiberRoot=="function")try{De.onPostCommitFiberRoot(ke,D)}catch{}return!0}finally{V.p=M,X.T=_,jP(o,u)}}function GP(o,u,p){u=Ma(p,u),u=S_(o.stateNode,u,2),o=ml(o,u,2),o!==null&&(Wt(o,2),Cs(o))}function xn(o,u,p){if(o.tag===3)GP(o,o,p);else for(;u!==null;){if(u.tag===3){GP(u,o,p);break}else if(u.tag===1){var _=u.stateNode;if(typeof u.type.getDerivedStateFromError=="function"||typeof _.componentDidCatch=="function"&&(_l===null||!_l.has(_))){o=Ma(p,o),p=VR(2),_=ml(u,p,2),_!==null&&($R(p,_,u,o),Wt(_,2),Cs(_));break}}u=u.return}}function X_(o,u,p){var _=o.pingCache;if(_===null){_=o.pingCache=new GV;var M=new Set;_.set(u,M)}else M=_.get(u),M===void 0&&(M=new Set,_.set(u,M));M.has(p)||(z_=!0,M.add(p),o=qV.bind(null,o,u,p),u.then(o,o))}function qV(o,u,p){var _=o.pingCache;_!==null&&_.delete(u),o.pingedLanes|=o.suspendedLanes&p,o.warmLanes&=~p,Pn===o&&(en&p)===p&&(Qn===4||Qn===3&&(en&62914560)===en&&300>O()-Ig?(hn&2)===0&&cd(o,0):B_|=p,od===en&&(od=0)),Cs(o)}function VP(o,u){u===0&&(u=mt()),o=Sc(o,u),o!==null&&(Wt(o,u),Cs(o))}function YV(o){var u=o.memoizedState,p=0;u!==null&&(p=u.retryLane),VP(o,p)}function KV(o,u){var p=0;switch(o.tag){case 31:case 13:var _=o.stateNode,M=o.memoizedState;M!==null&&(p=M.retryLane);break;case 19:_=o.stateNode;break;case 22:_=o.stateNode._retryCache;break;default:throw Error(i(314))}_!==null&&_.delete(u),VP(o,p)}function ZV(o,u){return ie(o,u)}var Hg=null,dd=null,q_=!1,Gg=!1,Y_=!1,El=0;function Cs(o){o!==dd&&o.next===null&&(dd===null?Hg=dd=o:dd=dd.next=o),Gg=!0,q_||(q_=!0,JV())}function kh(o,u){if(!Y_&&Gg){Y_=!0;do for(var p=!1,_=Hg;_!==null;){if(o!==0){var M=_.pendingLanes;if(M===0)var D=0;else{var j=_.suspendedLanes,te=_.pingedLanes;D=(1<<31-et(42|o)+1)-1,D&=M&~(j&~te),D=D&201326741?D&201326741|1:D?D|2:0}D!==0&&(p=!0,qP(_,D))}else D=en,D=ge(_,_===Pn?D:0,_.cancelPendingCommit!==null||_.timeoutHandle!==-1),(D&3)===0||je(_,D)||(p=!0,qP(_,D));_=_.next}while(p);Y_=!1}}function QV(){$P()}function $P(){Gg=q_=!1;var o=0;El!==0&&c9()&&(o=El);for(var u=O(),p=null,_=Hg;_!==null;){var M=_.next,D=WP(_,u);D===0?(_.next=null,p===null?Hg=M:p.next=M,M===null&&(dd=p)):(p=_,(o!==0||(D&3)!==0)&&(Gg=!0)),_=M}Mi!==0&&Mi!==5||kh(o),El!==0&&(El=0)}function WP(o,u){for(var p=o.suspendedLanes,_=o.pingedLanes,M=o.expirationTimes,D=o.pendingLanes&-62914561;0te)break;var Qe=Se.transferSize,it=Se.initiatorType;Qe&&nD(it)&&(Se=Se.responseEnd,j+=Qe*(Se"u"?null:document;function pD(o,u,p){var _=fd;if(_&&typeof u=="string"&&u){var M=pn(u);M='link[rel="'+o+'"][href="'+M+'"]',typeof p=="string"&&(M+='[crossorigin="'+p+'"]'),hD.has(M)||(hD.add(M),o={rel:o,crossOrigin:p,href:u},_.querySelector(M)===null&&(u=_.createElement("link"),sr(u,"link",o),Re(u),_.head.appendChild(u)))}}function x9(o){mo.D(o),pD("dns-prefetch",o,null)}function y9(o,u){mo.C(o,u),pD("preconnect",o,u)}function b9(o,u,p){mo.L(o,u,p);var _=fd;if(_&&o&&u){var M='link[rel="preload"][as="'+pn(u)+'"]';u==="image"&&p&&p.imageSrcSet?(M+='[imagesrcset="'+pn(p.imageSrcSet)+'"]',typeof p.imageSizes=="string"&&(M+='[imagesizes="'+pn(p.imageSizes)+'"]')):M+='[href="'+pn(o)+'"]';var D=M;switch(u){case"style":D=hd(o);break;case"script":D=pd(o)}Da.has(D)||(o=h({rel:"preload",href:u==="image"&&p&&p.imageSrcSet?void 0:o,as:u},p),Da.set(D,o),_.querySelector(M)!==null||u==="style"&&_.querySelector(jh(D))||u==="script"&&_.querySelector(Hh(D))||(u=_.createElement("link"),sr(u,"link",o),Re(u),_.head.appendChild(u)))}}function _9(o,u){mo.m(o,u);var p=fd;if(p&&o){var _=u&&typeof u.as=="string"?u.as:"script",M='link[rel="modulepreload"][as="'+pn(_)+'"][href="'+pn(o)+'"]',D=M;switch(_){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":D=pd(o)}if(!Da.has(D)&&(o=h({rel:"modulepreload",href:o},u),Da.set(D,o),p.querySelector(M)===null)){switch(_){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(p.querySelector(Hh(D)))return}_=p.createElement("link"),sr(_,"link",o),Re(_),p.head.appendChild(_)}}}function S9(o,u,p){mo.S(o,u,p);var _=fd;if(_&&o){var M=Pe(_).hoistableStyles,D=hd(o);u=u||"default";var j=M.get(D);if(!j){var te={loading:0,preload:null};if(j=_.querySelector(jh(D)))te.loading=5;else{o=h({rel:"stylesheet",href:o,"data-precedence":u},p),(p=Da.get(D))&&dS(o,p);var Se=j=_.createElement("link");Re(Se),sr(Se,"link",o),Se._p=new Promise(function(ze,Qe){Se.onload=ze,Se.onerror=Qe}),Se.addEventListener("load",function(){te.loading|=1}),Se.addEventListener("error",function(){te.loading|=2}),te.loading|=4,qg(j,u,_)}j={type:"stylesheet",instance:j,count:1,state:te},M.set(D,j)}}}function w9(o,u){mo.X(o,u);var p=fd;if(p&&o){var _=Pe(p).hoistableScripts,M=pd(o),D=_.get(M);D||(D=p.querySelector(Hh(M)),D||(o=h({src:o,async:!0},u),(u=Da.get(M))&&fS(o,u),D=p.createElement("script"),Re(D),sr(D,"link",o),p.head.appendChild(D)),D={type:"script",instance:D,count:1,state:null},_.set(M,D))}}function E9(o,u){mo.M(o,u);var p=fd;if(p&&o){var _=Pe(p).hoistableScripts,M=pd(o),D=_.get(M);D||(D=p.querySelector(Hh(M)),D||(o=h({src:o,async:!0,type:"module"},u),(u=Da.get(M))&&fS(o,u),D=p.createElement("script"),Re(D),sr(D,"link",o),p.head.appendChild(D)),D={type:"script",instance:D,count:1,state:null},_.set(M,D))}}function mD(o,u,p,_){var M=(M=me.current)?Xg(M):null;if(!M)throw Error(i(446));switch(o){case"meta":case"title":return null;case"style":return typeof p.precedence=="string"&&typeof p.href=="string"?(u=hd(p.href),p=Pe(M).hoistableStyles,_=p.get(u),_||(_={type:"style",instance:null,count:0,state:null},p.set(u,_)),_):{type:"void",instance:null,count:0,state:null};case"link":if(p.rel==="stylesheet"&&typeof p.href=="string"&&typeof p.precedence=="string"){o=hd(p.href);var D=Pe(M).hoistableStyles,j=D.get(o);if(j||(M=M.ownerDocument||M,j={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},D.set(o,j),(D=M.querySelector(jh(o)))&&!D._p&&(j.instance=D,j.state.loading=5),Da.has(o)||(p={rel:"preload",as:"style",href:p.href,crossOrigin:p.crossOrigin,integrity:p.integrity,media:p.media,hrefLang:p.hrefLang,referrerPolicy:p.referrerPolicy},Da.set(o,p),D||M9(M,o,p,j.state))),u&&_===null)throw Error(i(528,""));return j}if(u&&_!==null)throw Error(i(529,""));return null;case"script":return u=p.async,p=p.src,typeof p=="string"&&u&&typeof u!="function"&&typeof u!="symbol"?(u=pd(p),p=Pe(M).hoistableScripts,_=p.get(u),_||(_={type:"script",instance:null,count:0,state:null},p.set(u,_)),_):{type:"void",instance:null,count:0,state:null};default:throw Error(i(444,o))}}function hd(o){return'href="'+pn(o)+'"'}function jh(o){return'link[rel="stylesheet"]['+o+"]"}function gD(o){return h({},o,{"data-precedence":o.precedence,precedence:null})}function M9(o,u,p,_){o.querySelector('link[rel="preload"][as="style"]['+u+"]")?_.loading=1:(u=o.createElement("link"),_.preload=u,u.addEventListener("load",function(){return _.loading|=1}),u.addEventListener("error",function(){return _.loading|=2}),sr(u,"link",p),Re(u),o.head.appendChild(u))}function pd(o){return'[src="'+pn(o)+'"]'}function Hh(o){return"script[async]"+o}function vD(o,u,p){if(u.count++,u.instance===null)switch(u.type){case"style":var _=o.querySelector('style[data-href~="'+pn(p.href)+'"]');if(_)return u.instance=_,Re(_),_;var M=h({},p,{"data-href":p.href,"data-precedence":p.precedence,href:null,precedence:null});return _=(o.ownerDocument||o).createElement("style"),Re(_),sr(_,"style",M),qg(_,p.precedence,o),u.instance=_;case"stylesheet":M=hd(p.href);var D=o.querySelector(jh(M));if(D)return u.state.loading|=4,u.instance=D,Re(D),D;_=gD(p),(M=Da.get(M))&&dS(_,M),D=(o.ownerDocument||o).createElement("link"),Re(D);var j=D;return j._p=new Promise(function(te,Se){j.onload=te,j.onerror=Se}),sr(D,"link",_),u.state.loading|=4,qg(D,p.precedence,o),u.instance=D;case"script":return D=pd(p.src),(M=o.querySelector(Hh(D)))?(u.instance=M,Re(M),M):(_=p,(M=Da.get(D))&&(_=h({},p),fS(_,M)),o=o.ownerDocument||o,M=o.createElement("script"),Re(M),sr(M,"link",_),o.head.appendChild(M),u.instance=M);case"void":return null;default:throw Error(i(443,u.type))}else u.type==="stylesheet"&&(u.state.loading&4)===0&&(_=u.instance,u.state.loading|=4,qg(_,p.precedence,o));return u.instance}function qg(o,u,p){for(var _=p.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),M=_.length?_[_.length-1]:null,D=M,j=0;j<_.length;j++){var te=_[j];if(te.dataset.precedence===u)D=te;else if(D!==M)break}D?D.parentNode.insertBefore(o,D.nextSibling):(u=p.nodeType===9?p.head:p,u.insertBefore(o,u.firstChild))}function dS(o,u){o.crossOrigin==null&&(o.crossOrigin=u.crossOrigin),o.referrerPolicy==null&&(o.referrerPolicy=u.referrerPolicy),o.title==null&&(o.title=u.title)}function fS(o,u){o.crossOrigin==null&&(o.crossOrigin=u.crossOrigin),o.referrerPolicy==null&&(o.referrerPolicy=u.referrerPolicy),o.integrity==null&&(o.integrity=u.integrity)}var Yg=null;function xD(o,u,p){if(Yg===null){var _=new Map,M=Yg=new Map;M.set(p,_)}else M=Yg,_=M.get(p),_||(_=new Map,M.set(p,_));if(_.has(o))return _;for(_.set(o,null),p=p.getElementsByTagName(o),M=0;M title"):null)}function A9(o,u,p){if(p===1||u.itemProp!=null)return!1;switch(o){case"meta":case"title":return!0;case"style":if(typeof u.precedence!="string"||typeof u.href!="string"||u.href==="")break;return!0;case"link":if(typeof u.rel!="string"||typeof u.href!="string"||u.href===""||u.onLoad||u.onError)break;switch(u.rel){case"stylesheet":return o=u.disabled,typeof u.precedence=="string"&&o==null;default:return!0}case"script":if(u.async&&typeof u.async!="function"&&typeof u.async!="symbol"&&!u.onLoad&&!u.onError&&u.src&&typeof u.src=="string")return!0}return!1}function bD(o){return!(o.type==="stylesheet"&&(o.state.loading&3)===0)}function T9(o,u,p,_){if(p.type==="stylesheet"&&(typeof _.media!="string"||matchMedia(_.media).matches!==!1)&&(p.state.loading&4)===0){if(p.instance===null){var M=hd(_.href),D=u.querySelector(jh(M));if(D){u=D._p,u!==null&&typeof u=="object"&&typeof u.then=="function"&&(o.count++,o=Kg.bind(o),u.then(o,o)),p.state.loading|=4,p.instance=D,Re(D);return}D=u.ownerDocument||u,_=gD(_),(M=Da.get(M))&&dS(_,M),D=D.createElement("link"),Re(D);var j=D;j._p=new Promise(function(te,Se){j.onload=te,j.onerror=Se}),sr(D,"link",_),p.instance=D}o.stylesheets===null&&(o.stylesheets=new Map),o.stylesheets.set(p,u),(u=p.state.preload)&&(p.state.loading&3)===0&&(o.count++,p=Kg.bind(o),u.addEventListener("load",p),u.addEventListener("error",p))}}var hS=0;function C9(o,u){return o.stylesheets&&o.count===0&&Qg(o,o.stylesheets),0hS?50:800)+u);return o.unsuspend=p,function(){o.unsuspend=null,clearTimeout(_),clearTimeout(M)}}:null}function Kg(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Qg(this,this.stylesheets);else if(this.unsuspend){var o=this.unsuspend;this.unsuspend=null,o()}}}var Zg=null;function Qg(o,u){o.stylesheets=null,o.unsuspend!==null&&(o.count++,Zg=new Map,u.forEach(R9,o),Zg=null,Kg.call(o))}function R9(o,u){if(!(u.state.loading&4)){var p=Zg.get(o);if(p)var _=p.get(null);else{p=new Map,Zg.set(o,p);for(var M=o.querySelectorAll("link[data-precedence],style[data-precedence]"),D=0;D"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),SS.exports=W9(),SS.exports}var q9=X9();var VD="popstate";function Y9(t={}){function e(i,r){let{pathname:a,search:s,hash:l}=i.location;return LE("",{pathname:a,search:s,hash:l},r.state&&r.state.usr||null,r.state&&r.state.key||"default")}function n(i,r){return typeof r=="string"?r:Lp(r)}return Z9(e,n,null,t)}function Xn(t,e){if(t===!1||t===null||typeof t>"u")throw new Error(e)}function ys(t,e){if(!t){typeof console<"u"&&console.warn(e);try{throw new Error(e)}catch{}}}function K9(){return Math.random().toString(36).substring(2,10)}function $D(t,e){return{usr:t.state,key:t.key,idx:e}}function LE(t,e,n=null,i){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof e=="string"?Uf(e):e,state:n,key:e&&e.key||i||K9()}}function Lp({pathname:t="/",search:e="",hash:n=""}){return e&&e!=="?"&&(t+=e.charAt(0)==="?"?e:"?"+e),n&&n!=="#"&&(t+=n.charAt(0)==="#"?n:"#"+n),t}function Uf(t){let e={};if(t){let n=t.indexOf("#");n>=0&&(e.hash=t.substring(n),t=t.substring(0,n));let i=t.indexOf("?");i>=0&&(e.search=t.substring(i),t=t.substring(0,i)),t&&(e.pathname=t)}return e}function Z9(t,e,n,i={}){let{window:r=document.defaultView,v5Compat:a=!1}=i,s=r.history,l="POP",c=null,d=f();d==null&&(d=0,s.replaceState({...s.state,idx:d},""));function f(){return(s.state||{idx:null}).idx}function h(){l="POP";let x=f(),v=x==null?null:x-d;d=x,c&&c({action:l,location:y.location,delta:v})}function g(x,v){l="PUSH";let w=LE(y.location,x,v);d=f()+1;let S=$D(w,d),A=y.createHref(w);try{s.pushState(S,"",A)}catch(R){if(R instanceof DOMException&&R.name==="DataCloneError")throw R;r.location.assign(A)}a&&c&&c({action:l,location:y.location,delta:1})}function m(x,v){l="REPLACE";let w=LE(y.location,x,v);d=f();let S=$D(w,d),A=y.createHref(w);s.replaceState(S,"",A),a&&c&&c({action:l,location:y.location,delta:0})}function b(x){return Q9(x)}let y={get action(){return l},get location(){return t(r,s)},listen(x){if(c)throw new Error("A history only accepts one active listener");return r.addEventListener(VD,h),c=x,()=>{r.removeEventListener(VD,h),c=null}},createHref(x){return e(r,x)},createURL:b,encodeLocation(x){let v=b(x);return{pathname:v.pathname,search:v.search,hash:v.hash}},push:g,replace:m,go(x){return s.go(x)}};return y}function Q9(t,e=!1){let n="http://localhost";typeof window<"u"&&(n=window.location.origin!=="null"?window.location.origin:window.location.href),Xn(n,"No window.location.(origin|href) available to create URL");let i=typeof t=="string"?t:Lp(t);return i=i.replace(/ $/,"%20"),!e&&i.startsWith("//")&&(i=n+i),new URL(i,n)}function J4(t,e,n="/"){return J9(t,e,n,!1)}function J9(t,e,n,i){let r=typeof e=="string"?Uf(e):e,a=Vo(r.pathname||"/",n);if(a==null)return null;let s=ez(t);e$(s);let l=null;for(let c=0;l==null&&c{let f={relativePath:d===void 0?s.path||"":d,caseSensitive:s.caseSensitive===!0,childrenIndex:l,route:s};if(f.relativePath.startsWith("/")){if(!f.relativePath.startsWith(i)&&c)return;Xn(f.relativePath.startsWith(i),`Absolute route path "${f.relativePath}" nested under path "${i}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),f.relativePath=f.relativePath.slice(i.length)}let h=Fo([i,f.relativePath]),g=n.concat(f);s.children&&s.children.length>0&&(Xn(s.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${h}".`),ez(s.children,e,g,h,c)),!(s.path==null&&!s.index)&&e.push({path:h,score:o$(h,s.index),routesMeta:g})};return t.forEach((s,l)=>{if(s.path===""||!s.path?.includes("?"))a(s,l);else for(let c of tz(s.path))a(s,l,!0,c)}),e}function tz(t){let e=t.split("/");if(e.length===0)return[];let[n,...i]=e,r=n.endsWith("?"),a=n.replace(/\?$/,"");if(i.length===0)return r?[a,""]:[a];let s=tz(i.join("/")),l=[];return l.push(...s.map(c=>c===""?a:[a,c].join("/"))),r&&l.push(...s),l.map(c=>t.startsWith("/")&&c===""?"/":c)}function e$(t){t.sort((e,n)=>e.score!==n.score?n.score-e.score:l$(e.routesMeta.map(i=>i.childrenIndex),n.routesMeta.map(i=>i.childrenIndex)))}var t$=/^:[\w-]+$/,n$=3,i$=2,r$=1,a$=10,s$=-2,WD=t=>t==="*";function o$(t,e){let n=t.split("/"),i=n.length;return n.some(WD)&&(i+=s$),e&&(i+=i$),n.filter(r=>!WD(r)).reduce((r,a)=>r+(t$.test(a)?n$:a===""?r$:a$),i)}function l$(t,e){return t.length===e.length&&t.slice(0,-1).every((i,r)=>i===e[r])?t[t.length-1]-e[e.length-1]:0}function c$(t,e,n=!1){let{routesMeta:i}=t,r={},a="/",s=[];for(let l=0;l{if(f==="*"){let b=l[g]||"";s=a.slice(0,a.length-b.length).replace(/(.)\/+$/,"$1")}const m=l[g];return h&&!m?d[f]=void 0:d[f]=(m||"").replace(/%2F/g,"/"),d},{}),pathname:a,pathnameBase:s,pattern:t}}function u$(t,e=!1,n=!0){ys(t==="*"||!t.endsWith("*")||t.endsWith("/*"),`Route path "${t}" will be treated as if it were "${t.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${t.replace(/\*$/,"/*")}".`);let i=[],r="^"+t.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(s,l,c)=>(i.push({paramName:l,isOptional:c!=null}),c?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return t.endsWith("*")?(i.push({paramName:"*"}),r+=t==="*"||t==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?r+="\\/*$":t!==""&&t!=="/"&&(r+="(?:(?=\\/|$))"),[new RegExp(r,e?void 0:"i"),i]}function d$(t){try{return t.split("/").map(e=>decodeURIComponent(e).replace(/\//g,"%2F")).join("/")}catch(e){return ys(!1,`The URL path "${t}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${e}).`),t}}function Vo(t,e){if(e==="/")return t;if(!t.toLowerCase().startsWith(e.toLowerCase()))return null;let n=e.endsWith("/")?e.length-1:e.length,i=t.charAt(n);return i&&i!=="/"?null:t.slice(n)||"/"}var f$=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,h$=t=>f$.test(t);function p$(t,e="/"){let{pathname:n,search:i="",hash:r=""}=typeof t=="string"?Uf(t):t,a;if(n)if(h$(n))a=n;else{if(n.includes("//")){let s=n;n=n.replace(/\/\/+/g,"/"),ys(!1,`Pathnames cannot have embedded double slashes - normalizing ${s} -> ${n}`)}n.startsWith("/")?a=XD(n.substring(1),"/"):a=XD(n,e)}else a=e;return{pathname:a,search:v$(i),hash:x$(r)}}function XD(t,e){let n=e.replace(/\/+$/,"").split("/");return t.split("/").forEach(r=>{r===".."?n.length>1&&n.pop():r!=="."&&n.push(r)}),n.length>1?n.join("/"):"/"}function AS(t,e,n,i){return`Cannot include a '${t}' character in a manually specified \`to.${e}\` field [${JSON.stringify(i)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function m$(t){return t.filter((e,n)=>n===0||e.route.path&&e.route.path.length>0)}function nz(t){let e=m$(t);return e.map((n,i)=>i===e.length-1?n.pathname:n.pathnameBase)}function iz(t,e,n,i=!1){let r;typeof t=="string"?r=Uf(t):(r={...t},Xn(!r.pathname||!r.pathname.includes("?"),AS("?","pathname","search",r)),Xn(!r.pathname||!r.pathname.includes("#"),AS("#","pathname","hash",r)),Xn(!r.search||!r.search.includes("#"),AS("#","search","hash",r)));let a=t===""||r.pathname==="",s=a?"/":r.pathname,l;if(s==null)l=n;else{let h=e.length-1;if(!i&&s.startsWith("..")){let g=s.split("/");for(;g[0]==="..";)g.shift(),h-=1;r.pathname=g.join("/")}l=h>=0?e[h]:"/"}let c=p$(r,l),d=s&&s!=="/"&&s.endsWith("/"),f=(a||s===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(d||f)&&(c.pathname+="/"),c}var Fo=t=>t.join("/").replace(/\/\/+/g,"/"),g$=t=>t.replace(/\/+$/,"").replace(/^\/*/,"/"),v$=t=>!t||t==="?"?"":t.startsWith("?")?t:"?"+t,x$=t=>!t||t==="#"?"":t.startsWith("#")?t:"#"+t;function y$(t){return t!=null&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.internal=="boolean"&&"data"in t}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var rz=["POST","PUT","PATCH","DELETE"];new Set(rz);var b$=["GET",...rz];new Set(b$);var kf=T.createContext(null);kf.displayName="DataRouter";var cy=T.createContext(null);cy.displayName="DataRouterState";T.createContext(!1);var az=T.createContext({isTransitioning:!1});az.displayName="ViewTransition";var _$=T.createContext(new Map);_$.displayName="Fetchers";var S$=T.createContext(null);S$.displayName="Await";var qs=T.createContext(null);qs.displayName="Navigation";var pm=T.createContext(null);pm.displayName="Location";var Ys=T.createContext({outlet:null,matches:[],isDataRoute:!1});Ys.displayName="Route";var nT=T.createContext(null);nT.displayName="RouteError";function w$(t,{relative:e}={}){Xn(mm(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:i}=T.useContext(qs),{hash:r,pathname:a,search:s}=gm(t,{relative:e}),l=a;return n!=="/"&&(l=a==="/"?n:Fo([n,a])),i.createHref({pathname:l,search:s,hash:r})}function mm(){return T.useContext(pm)!=null}function Ou(){return Xn(mm(),"useLocation() may be used only in the context of a component."),T.useContext(pm).location}var sz="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function oz(t){T.useContext(qs).static||T.useLayoutEffect(t)}function lz(){let{isDataRoute:t}=T.useContext(Ys);return t?F$():E$()}function E$(){Xn(mm(),"useNavigate() may be used only in the context of a component.");let t=T.useContext(kf),{basename:e,navigator:n}=T.useContext(qs),{matches:i}=T.useContext(Ys),{pathname:r}=Ou(),a=JSON.stringify(nz(i)),s=T.useRef(!1);return oz(()=>{s.current=!0}),T.useCallback((c,d={})=>{if(ys(s.current,sz),!s.current)return;if(typeof c=="number"){n.go(c);return}let f=iz(c,JSON.parse(a),r,d.relative==="path");t==null&&e!=="/"&&(f.pathname=f.pathname==="/"?e:Fo([e,f.pathname])),(d.replace?n.replace:n.push)(f,d.state,d)},[e,n,a,r,t])}var M$=T.createContext(null);function A$(t){let e=T.useContext(Ys).outlet;return T.useMemo(()=>e&&T.createElement(M$.Provider,{value:t},e),[e,t])}function gm(t,{relative:e}={}){let{matches:n}=T.useContext(Ys),{pathname:i}=Ou(),r=JSON.stringify(nz(n));return T.useMemo(()=>iz(t,JSON.parse(r),i,e==="path"),[t,r,i,e])}function T$(t,e){return cz(t,e)}function cz(t,e,n,i,r){Xn(mm(),"useRoutes() may be used only in the context of a component.");let{navigator:a}=T.useContext(qs),{matches:s}=T.useContext(Ys),l=s[s.length-1],c=l?l.params:{},d=l?l.pathname:"/",f=l?l.pathnameBase:"/",h=l&&l.route;{let w=h&&h.path||"";uz(d,!h||w.endsWith("*")||w.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${d}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let g=Ou(),m;if(e){let w=typeof e=="string"?Uf(e):e;Xn(f==="/"||w.pathname?.startsWith(f),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${f}" but pathname "${w.pathname}" was given in the \`location\` prop.`),m=w}else m=g;let b=m.pathname||"/",y=b;if(f!=="/"){let w=f.replace(/^\//,"").split("/");y="/"+b.replace(/^\//,"").split("/").slice(w.length).join("/")}let x=J4(t,{pathname:y});ys(h||x!=null,`No routes matched location "${m.pathname}${m.search}${m.hash}" `),ys(x==null||x[x.length-1].route.element!==void 0||x[x.length-1].route.Component!==void 0||x[x.length-1].route.lazy!==void 0,`Matched leaf route at location "${m.pathname}${m.search}${m.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let v=N$(x&&x.map(w=>Object.assign({},w,{params:Object.assign({},c,w.params),pathname:Fo([f,a.encodeLocation?a.encodeLocation(w.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:w.pathname]),pathnameBase:w.pathnameBase==="/"?f:Fo([f,a.encodeLocation?a.encodeLocation(w.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:w.pathnameBase])})),s,n,i,r);return e&&v?T.createElement(pm.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...m},navigationType:"POP"}},v):v}function C$(){let t=k$(),e=y$(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),n=t instanceof Error?t.stack:null,i="rgba(200,200,200, 0.5)",r={padding:"0.5rem",backgroundColor:i},a={padding:"2px 4px",backgroundColor:i},s=null;return console.error("Error handled by React Router default ErrorBoundary:",t),s=T.createElement(T.Fragment,null,T.createElement("p",null,"💿 Hey developer 👋"),T.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",T.createElement("code",{style:a},"ErrorBoundary")," or"," ",T.createElement("code",{style:a},"errorElement")," prop on your route.")),T.createElement(T.Fragment,null,T.createElement("h2",null,"Unexpected Application Error!"),T.createElement("h3",{style:{fontStyle:"italic"}},e),n?T.createElement("pre",{style:r},n):null,s)}var R$=T.createElement(C$,null),P$=class extends T.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,e){return e.location!==t.location||e.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:e.error,location:e.location,revalidation:t.revalidation||e.revalidation}}componentDidCatch(t,e){this.props.onError?this.props.onError(t,e):console.error("React Router caught the following error during render",t)}render(){return this.state.error!==void 0?T.createElement(Ys.Provider,{value:this.props.routeContext},T.createElement(nT.Provider,{value:this.state.error,children:this.props.component})):this.props.children}};function D$({routeContext:t,match:e,children:n}){let i=T.useContext(kf);return i&&i.static&&i.staticContext&&(e.route.errorElement||e.route.ErrorBoundary)&&(i.staticContext._deepestRenderedBoundaryId=e.route.id),T.createElement(Ys.Provider,{value:t},n)}function N$(t,e=[],n=null,i=null,r=null){if(t==null){if(!n)return null;if(n.errors)t=n.matches;else if(e.length===0&&!n.initialized&&n.matches.length>0)t=n.matches;else return null}let a=t,s=n?.errors;if(s!=null){let f=a.findIndex(h=>h.route.id&&s?.[h.route.id]!==void 0);Xn(f>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(s).join(",")}`),a=a.slice(0,Math.min(a.length,f+1))}let l=!1,c=-1;if(n)for(let f=0;f=0?a=a.slice(0,c+1):a=[a[0]];break}}}let d=n&&i?(f,h)=>{i(f,{location:n.location,params:n.matches?.[0]?.params??{},errorInfo:h})}:void 0;return a.reduceRight((f,h,g)=>{let m,b=!1,y=null,x=null;n&&(m=s&&h.route.id?s[h.route.id]:void 0,y=h.route.errorElement||R$,l&&(c<0&&g===0?(uz("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),b=!0,x=null):c===g&&(b=!0,x=h.route.hydrateFallbackElement||null)));let v=e.concat(a.slice(0,g+1)),w=()=>{let S;return m?S=y:b?S=x:h.route.Component?S=T.createElement(h.route.Component,null):h.route.element?S=h.route.element:S=f,T.createElement(D$,{match:h,routeContext:{outlet:f,matches:v,isDataRoute:n!=null},children:S})};return n&&(h.route.ErrorBoundary||h.route.errorElement||g===0)?T.createElement(P$,{location:n.location,revalidation:n.revalidation,component:y,error:m,children:w(),routeContext:{outlet:null,matches:v,isDataRoute:!0},onError:d}):w()},null)}function iT(t){return`${t} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function O$(t){let e=T.useContext(kf);return Xn(e,iT(t)),e}function L$(t){let e=T.useContext(cy);return Xn(e,iT(t)),e}function I$(t){let e=T.useContext(Ys);return Xn(e,iT(t)),e}function rT(t){let e=I$(t),n=e.matches[e.matches.length-1];return Xn(n.route.id,`${t} can only be used on routes that contain a unique "id"`),n.route.id}function U$(){return rT("useRouteId")}function k$(){let t=T.useContext(nT),e=L$("useRouteError"),n=rT("useRouteError");return t!==void 0?t:e.errors?.[n]}function F$(){let{router:t}=O$("useNavigate"),e=rT("useNavigate"),n=T.useRef(!1);return oz(()=>{n.current=!0}),T.useCallback(async(r,a={})=>{ys(n.current,sz),n.current&&(typeof r=="number"?t.navigate(r):await t.navigate(r,{fromRouteId:e,...a}))},[t,e])}var qD={};function uz(t,e,n){!e&&!qD[t]&&(qD[t]=!0,ys(!1,n))}T.memo(z$);function z$({routes:t,future:e,state:n,unstable_onError:i}){return cz(t,void 0,n,i,e)}function B$(t){return A$(t.context)}function Wc(t){Xn(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function j$({basename:t="/",children:e=null,location:n,navigationType:i="POP",navigator:r,static:a=!1}){Xn(!mm(),"You cannot render a inside another . You should never have more than one in your app.");let s=t.replace(/^\/*/,"/"),l=T.useMemo(()=>({basename:s,navigator:r,static:a,future:{}}),[s,r,a]);typeof n=="string"&&(n=Uf(n));let{pathname:c="/",search:d="",hash:f="",state:h=null,key:g="default"}=n,m=T.useMemo(()=>{let b=Vo(c,s);return b==null?null:{location:{pathname:b,search:d,hash:f,state:h,key:g},navigationType:i}},[s,c,d,f,h,g,i]);return ys(m!=null,` is not able to match the URL "${c}${d}${f}" because it does not start with the basename, so the won't render anything.`),m==null?null:T.createElement(qs.Provider,{value:l},T.createElement(pm.Provider,{children:e,value:m}))}function H$({children:t,location:e}){return T$(IE(t),e)}function IE(t,e=[]){let n=[];return T.Children.forEach(t,(i,r)=>{if(!T.isValidElement(i))return;let a=[...e,r];if(i.type===T.Fragment){n.push.apply(n,IE(i.props.children,a));return}Xn(i.type===Wc,`[${typeof i.type=="string"?i.type:i.type.name}] is not a component. All component children of must be a or `),Xn(!i.props.index||!i.props.children,"An index route cannot have child routes.");let s={id:i.props.id||a.join("-"),caseSensitive:i.props.caseSensitive,element:i.props.element,Component:i.props.Component,index:i.props.index,path:i.props.path,middleware:i.props.middleware,loader:i.props.loader,action:i.props.action,hydrateFallbackElement:i.props.hydrateFallbackElement,HydrateFallback:i.props.HydrateFallback,errorElement:i.props.errorElement,ErrorBoundary:i.props.ErrorBoundary,hasErrorBoundary:i.props.hasErrorBoundary===!0||i.props.ErrorBoundary!=null||i.props.errorElement!=null,shouldRevalidate:i.props.shouldRevalidate,handle:i.props.handle,lazy:i.props.lazy};i.props.children&&(s.children=IE(i.props.children,a)),n.push(s)}),n}var Tv="get",Cv="application/x-www-form-urlencoded";function uy(t){return t!=null&&typeof t.tagName=="string"}function G$(t){return uy(t)&&t.tagName.toLowerCase()==="button"}function V$(t){return uy(t)&&t.tagName.toLowerCase()==="form"}function $$(t){return uy(t)&&t.tagName.toLowerCase()==="input"}function W$(t){return!!(t.metaKey||t.altKey||t.ctrlKey||t.shiftKey)}function X$(t,e){return t.button===0&&(!e||e==="_self")&&!W$(t)}var o0=null;function q$(){if(o0===null)try{new FormData(document.createElement("form"),0),o0=!1}catch{o0=!0}return o0}var Y$=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function TS(t){return t!=null&&!Y$.has(t)?(ys(!1,`"${t}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Cv}"`),null):t}function K$(t,e){let n,i,r,a,s;if(V$(t)){let l=t.getAttribute("action");i=l?Vo(l,e):null,n=t.getAttribute("method")||Tv,r=TS(t.getAttribute("enctype"))||Cv,a=new FormData(t)}else if(G$(t)||$$(t)&&(t.type==="submit"||t.type==="image")){let l=t.form;if(l==null)throw new Error('Cannot submit a