@@ -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://<plug-ip>/cm?cmnd=Power` to test directly
|
||||
3. **Check Tasmota web interface** - Access `http://<plug-ip>` 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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
@@ -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: <metadata name="Key">Value</metadata> or <metadata name="Key" />
|
||||
metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
|
||||
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'(<metadata\s+name="{xml_name}"[^>]*>)[^<]*(</metadata>)'
|
||||
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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 995 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -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<string, number>;
|
||||
prints_by_printer: Record<string, number>;
|
||||
average_time_accuracy: number | null;
|
||||
time_accuracy_by_printer: Record<string, number> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
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<MQTTLogsResponse>(`/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<void>(`/archives/${id}`, { method: 'DELETE' }),
|
||||
getArchiveStats: () => request<ArchiveStats>('/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<Record<string, unknown>>(`/cloud/settings/${settingId}`),
|
||||
getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
|
||||
|
||||
// Smart Plugs
|
||||
getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
|
||||
getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
|
||||
createSmartPlug: (data: SmartPlugCreate) =>
|
||||
request<SmartPlug>('/smart-plugs/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateSmartPlug: (id: number, data: SmartPlugUpdate) =>
|
||||
request<SmartPlug>(`/smart-plugs/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteSmartPlug: (id: number) =>
|
||||
request<void>(`/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<SmartPlugStatus>(`/smart-plugs/${id}/status`),
|
||||
testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null) =>
|
||||
request<SmartPlugTestResult>('/smart-plugs/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ip_address, username, password }),
|
||||
}),
|
||||
|
||||
};
|
||||
|
||||
@@ -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<number | null>(plug?.printer_id || null);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP Address */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={ipAddress}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={!ipAddress.trim() || testMutation.isPending}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Wifi className="w-4 h-4" />
|
||||
)}
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`p-3 rounded-lg flex items-center gap-2 ${
|
||||
testResult.success
|
||||
? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
|
||||
: 'bg-red-500/20 border border-red-500/50 text-red-400'
|
||||
}`}>
|
||||
{testResult.success ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<div>
|
||||
<p className="font-medium">Connected!</p>
|
||||
<p className="text-sm opacity-80">
|
||||
{testResult.device_name && `Device: ${testResult.device_name} - `}
|
||||
State: {testResult.state}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-5 h-5" />
|
||||
<span>Connection failed</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Authentication (optional) */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-bambu-gray -mt-2">
|
||||
Leave empty if your Tasmota device doesn't require authentication
|
||||
</p>
|
||||
|
||||
{/* Link to Printer */}
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
|
||||
<select
|
||||
value={printerId ?? ''}
|
||||
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
|
||||
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"
|
||||
>
|
||||
<option value="">No printer (manual control only)</option>
|
||||
{availablePrinters?.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
Linking enables automatic on/off when prints start/complete
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{isEditing ? 'Save' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
<div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-center overflow-hidden">
|
||||
<div className={`${sidebarExpanded ? '' : 'w-10 h-10 overflow-hidden'}`}>
|
||||
<img
|
||||
src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
|
||||
alt="Bambusy"
|
||||
className={sidebarExpanded ? 'h-16 w-auto' : 'h-10 w-auto max-w-none'}
|
||||
/>
|
||||
</div>
|
||||
<div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${sidebarExpanded ? 'p-4' : 'p-2'}`}>
|
||||
<img
|
||||
src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
|
||||
alt="Bambusy"
|
||||
className={sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -128,9 +126,37 @@ export function Layout() {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-2 border-t border-bambu-dark-tertiary">
|
||||
<div className={`flex items-center ${sidebarExpanded ? 'justify-between px-2' : 'flex-col gap-2'}`}>
|
||||
{sidebarExpanded && <span className="text-sm text-bambu-gray">v0.1.1</span>}
|
||||
<div className="flex items-center gap-1">
|
||||
{sidebarExpanded ? (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-bambu-gray">v0.1.2</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://github.com/maziggy/bambusy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<Github className="w-5 h-5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setShowShortcuts(true)}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title="Keyboard shortcuts (?)"
|
||||
>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<a
|
||||
href="https://github.com/maziggy/bambusy"
|
||||
target="_blank"
|
||||
@@ -152,14 +178,10 @@ export function Layout() {
|
||||
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
{theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -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<Set<number>>(new Set());
|
||||
const logContainerRef = useRef<HTMLDivElement>(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<string, unknown>, 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 (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
|
||||
<p className="text-sm text-bambu-gray">{printerName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 p-4 border-b border-bambu-dark-tertiary">
|
||||
{loggingEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => disableMutation.mutate()}
|
||||
disabled={disableMutation.isPending}
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => enableMutation.mutate()}
|
||||
disabled={enableMutation.isPending}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
Start Logging
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => clearMutation.mutate()}
|
||||
disabled={clearMutation.isPending || logs.length === 0}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
className="rounded border-bambu-dark-tertiary"
|
||||
/>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{logs.length} message{logs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Log Content */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-auto p-4 font-mono text-xs bg-bambu-dark min-h-[400px]"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-bambu-gray">
|
||||
<p className="mb-2">No messages logged yet</p>
|
||||
{!loggingEnabled && (
|
||||
<p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{logs.map((log: MQTTLogEntry, index: number) => {
|
||||
const isExpanded = expandedLogs.has(index);
|
||||
const isIncoming = log.direction === 'in';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-2 rounded cursor-pointer hover:bg-bambu-dark-secondary transition-colors ${
|
||||
isExpanded ? 'bg-bambu-dark-secondary' : ''
|
||||
}`}
|
||||
onClick={() => toggleExpand(index)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-bambu-gray shrink-0">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={`shrink-0 ${
|
||||
isIncoming ? 'text-blue-400' : 'text-green-400'
|
||||
}`}
|
||||
title={isIncoming ? 'Incoming' : 'Outgoing'}
|
||||
>
|
||||
{isIncoming ? (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-purple-400 shrink-0">{log.topic}</span>
|
||||
</div>
|
||||
<pre
|
||||
className={`mt-1 text-white/80 overflow-x-auto ${
|
||||
isExpanded ? 'whitespace-pre-wrap' : 'truncate'
|
||||
}`}
|
||||
>
|
||||
{formatPayload(log.payload, isExpanded)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-sm text-bambu-gray">
|
||||
{loggingEnabled ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Logging active - messages will auto-refresh
|
||||
</span>
|
||||
) : (
|
||||
<span>Logging stopped</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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}</${tag}>`;
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-bambu-dark-secondary rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Project Page
|
||||
{archiveName && <span className="text-bambu-gray ml-2">- {archiveName}</span>}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing && hasContent && (
|
||||
<Button variant="ghost" size="sm" onClick={handleStartEdit}>
|
||||
<Edit3 className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-bambu-gray" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-bambu-green border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 text-center py-12">
|
||||
Failed to load project page data
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectPage && !hasContent && (
|
||||
<div className="text-bambu-gray text-center py-12">
|
||||
<FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No project page data found in this 3MF file.</p>
|
||||
<p className="text-sm mt-2">
|
||||
Project pages are typically included in files downloaded from MakerWorld.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projectPage && hasContent && (
|
||||
<div className="space-y-6">
|
||||
{/* Title & Designer */}
|
||||
<div className="space-y-4">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.title || ''}
|
||||
onChange={(e) => 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 && (
|
||||
<h3 className="text-xl font-semibold text-white">{projectPage.title}</h3>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
value={editData.designer || ''}
|
||||
onChange={(e) => setEditData({ ...editData, designer: e.target.value })}
|
||||
placeholder="Designer"
|
||||
className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
projectPage.designer && (
|
||||
<div className="flex items-center gap-2 text-bambu-gray">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{projectPage.designer}</span>
|
||||
{projectPage.designer_user_id && (
|
||||
<a
|
||||
href={`https://makerworld.com/en/@${projectPage.designer_user_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-bambu-green hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{projectPage.creation_date && (
|
||||
<div className="flex items-center gap-2 text-bambu-gray">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{projectPage.creation_date}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
value={editData.license || ''}
|
||||
onChange={(e) => setEditData({ ...editData, license: e.target.value })}
|
||||
placeholder="License"
|
||||
className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
projectPage.license && (
|
||||
<div className="flex items-center gap-2 text-bambu-gray">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>{projectPage.license}</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{projectPage.origin && (
|
||||
<span className="px-2 py-0.5 bg-bambu-dark rounded text-bambu-gray">
|
||||
{projectPage.origin}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{(projectPage.description || isEditing) && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
|
||||
Description
|
||||
</h4>
|
||||
{isEditing ? (
|
||||
<RichTextEditor
|
||||
content={editData.description || ''}
|
||||
onChange={(html) => setEditData({ ...editData, description: html })}
|
||||
placeholder="Enter description..."
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(projectPage.description || ''),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Info */}
|
||||
{(projectPage.profile_title || projectPage.profile_description || isEditing) && (
|
||||
<div className="space-y-2 p-4 bg-bambu-dark rounded-lg">
|
||||
<h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
|
||||
Print Profile
|
||||
</h4>
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editData.profile_title || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<RichTextEditor
|
||||
content={editData.profile_description || ''}
|
||||
onChange={(html) => setEditData({ ...editData, profile_description: html })}
|
||||
placeholder="Profile description..."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{projectPage.profile_title && (
|
||||
<p className="text-white font-medium">{projectPage.profile_title}</p>
|
||||
)}
|
||||
{projectPage.profile_description && (
|
||||
<div
|
||||
className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(projectPage.profile_description),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{projectPage.profile_user_name && (
|
||||
<p className="text-sm text-bambu-gray">
|
||||
by {projectPage.profile_user_name}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Gallery */}
|
||||
{allImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Images ({allImages.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
|
||||
{allImages.map((img, index) => (
|
||||
<button
|
||||
key={img.path}
|
||||
onClick={() => setSelectedImageIndex(index)}
|
||||
className="aspect-square rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MakerWorld Link */}
|
||||
{projectPage.design_model_id && (
|
||||
<div className="pt-4 border-t border-bambu-dark-tertiary">
|
||||
<a
|
||||
href={`https://makerworld.com/en/models/${projectPage.design_model_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-bambu-green hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View on MakerWorld
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Lightbox */}
|
||||
{selectedImageIndex !== null && allImages[selectedImageIndex] && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
|
||||
onClick={() => setSelectedImageIndex(null)}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedImageIndex(Math.max(0, selectedImageIndex - 1));
|
||||
}}
|
||||
disabled={selectedImageIndex === 0}
|
||||
className="absolute left-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={allImages[selectedImageIndex].url}
|
||||
alt={allImages[selectedImageIndex].name}
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedImageIndex(Math.min(allImages.length - 1, selectedImageIndex + 1));
|
||||
}}
|
||||
disabled={selectedImageIndex === allImages.length - 1}
|
||||
className="absolute right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedImageIndex(null)}
|
||||
className="absolute top-4 right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary"
|
||||
>
|
||||
<X className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 text-white text-sm">
|
||||
{selectedImageIndex + 1} / {allImages.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`p-1.5 rounded hover:bg-bambu-dark-tertiary transition-colors ${
|
||||
isActive ? 'bg-bambu-dark-tertiary text-bambu-green' : 'text-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const setLink = () => {
|
||||
const url = window.prompt('Enter URL:');
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden bg-bambu-dark">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-0.5 p-1.5 border-b border-bambu-dark-tertiary bg-bambu-dark-secondary">
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic"
|
||||
>
|
||||
<Italic className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
isActive={editor.isActive('underline')}
|
||||
title="Underline"
|
||||
>
|
||||
<UnderlineIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Numbered List"
|
||||
>
|
||||
<ListOrdered className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
isActive={editor.isActive({ textAlign: 'left' })}
|
||||
title="Align Left"
|
||||
>
|
||||
<AlignLeft className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
isActive={editor.isActive({ textAlign: 'center' })}
|
||||
title="Align Center"
|
||||
>
|
||||
<AlignCenter className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
isActive={editor.isActive({ textAlign: 'right' })}
|
||||
title="Align Right"
|
||||
>
|
||||
<AlignRight className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={setLink}
|
||||
isActive={editor.isActive('link')}
|
||||
title="Add Link"
|
||||
>
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
{editor.isActive('link') && (
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||
title="Remove Link"
|
||||
>
|
||||
<Unlink className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Card className="relative">
|
||||
<CardContent className="p-4">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
|
||||
<Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">{plug.name}</h3>
|
||||
<p className="text-sm text-bambu-gray">{plug.ip_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{statusLoading ? (
|
||||
<Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
|
||||
) : isReachable ? (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Wifi className="w-4 h-4 text-bambu-green" />
|
||||
<span className={isOn ? 'text-bambu-green' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-sm text-red-400">
|
||||
<WifiOff className="w-4 h-4" />
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Linked Printer */}
|
||||
{linkedPrinter && (
|
||||
<div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
|
||||
<span className="text-xs text-bambu-gray">Linked to: </span>
|
||||
<span className="text-sm text-white">{linkedPrinter.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Controls */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isOn ? 'primary' : 'secondary'}
|
||||
disabled={!isReachable || isPending}
|
||||
onClick={() => controlMutation.mutate('on')}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
|
||||
On
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={!isOn ? 'primary' : 'secondary'}
|
||||
disabled={!isReachable || isPending}
|
||||
onClick={() => controlMutation.mutate('off')}
|
||||
className="flex-1"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
|
||||
Off
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Toggle Settings Panel */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Settings2 className="w-4 h-4" />
|
||||
Automation Settings
|
||||
</span>
|
||||
<span>{isExpanded ? '-' : '+'}</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded Settings */}
|
||||
{isExpanded && (
|
||||
<div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
|
||||
{/* Enabled Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Enabled</p>
|
||||
<p className="text-xs text-bambu-gray">Enable automation for this plug</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plug.enabled}
|
||||
onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto On */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Auto On</p>
|
||||
<p className="text-xs text-bambu-gray">Turn on when print starts</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plug.auto_on}
|
||||
onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Auto Off */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Auto Off</p>
|
||||
<p className="text-xs text-bambu-gray">Turn off when print completes</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plug.auto_off}
|
||||
onChange={(e) => updateMutation.mutate({ auto_off: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Delay Mode */}
|
||||
{plug.auto_off && (
|
||||
<div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
|
||||
<div>
|
||||
<p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
plug.off_delay_mode === 'time'
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
Time
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
plug.off_delay_mode === 'temperature'
|
||||
? 'bg-bambu-green text-white'
|
||||
: 'bg-bambu-dark text-bambu-gray hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Thermometer className="w-4 h-4" />
|
||||
Temp
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plug.off_delay_mode === 'time' ? (
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={plug.off_delay_minutes}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs text-bambu-gray mb-1">Temperature threshold (C)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="30"
|
||||
max="100"
|
||||
value={plug.off_temp_threshold}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">Turns off when nozzle cools below this temperature</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onEdit(plug)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmModal
|
||||
title="Delete Smart Plug"
|
||||
message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
deleteMutation.mutate();
|
||||
setShowDeleteConfirm(false);
|
||||
}}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: <FileText className="w-4 h-4" />,
|
||||
onClick: () => setShowProjectPage(true),
|
||||
},
|
||||
{ label: '', divider: true, onClick: () => {} },
|
||||
{
|
||||
label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
@@ -285,6 +293,16 @@ function ArchiveCard({
|
||||
failed
|
||||
</div>
|
||||
)}
|
||||
{/* Duplicate badge */}
|
||||
{archive.duplicate_count > 0 && (
|
||||
<div
|
||||
className="absolute top-2 right-2 px-2 py-1 rounded text-xs bg-purple-500/80 text-white flex items-center gap-1"
|
||||
title="This model has been printed before"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
duplicate
|
||||
</div>
|
||||
)}
|
||||
{/* Timelapse badge */}
|
||||
{archive.timelapse_path && (
|
||||
<button
|
||||
@@ -327,10 +345,27 @@ function ArchiveCard({
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs mb-4 min-h-[48px]">
|
||||
{archive.print_time_seconds && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-gray">
|
||||
{(archive.print_time_seconds || archive.actual_time_seconds) && (
|
||||
<div className="flex items-center gap-1.5 text-bambu-gray" title={
|
||||
archive.time_accuracy
|
||||
? `Estimated: ${formatDuration(archive.print_time_seconds || 0)}\nActual: ${formatDuration(archive.actual_time_seconds || 0)}\nAccuracy: ${archive.time_accuracy.toFixed(0)}%`
|
||||
: archive.actual_time_seconds
|
||||
? `Actual: ${formatDuration(archive.actual_time_seconds)}`
|
||||
: `Estimated: ${formatDuration(archive.print_time_seconds || 0)}`
|
||||
}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDuration(archive.print_time_seconds)}
|
||||
{formatDuration(archive.actual_time_seconds || archive.print_time_seconds || 0)}
|
||||
{archive.time_accuracy && (
|
||||
<span className={`text-[10px] px-1 rounded ${
|
||||
archive.time_accuracy >= 95 && archive.time_accuracy <= 105
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: archive.time_accuracy > 105
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-orange-500/20 text-orange-400'
|
||||
}`}>
|
||||
{archive.time_accuracy > 100 ? '+' : ''}{(archive.time_accuracy - 100).toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{archive.filament_used_grams && (
|
||||
@@ -598,13 +633,22 @@ function ArchiveCard({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Project Page Modal */}
|
||||
{showProjectPage && (
|
||||
<ProjectPageModal
|
||||
archiveId={archive.id}
|
||||
archiveName={archive.print_name || archive.filename}
|
||||
onClose={() => setShowProjectPage(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type SortOption = 'date-desc' | 'date-asc' | 'name-asc' | 'name-desc' | 'size-desc' | 'size-asc';
|
||||
type ViewMode = 'grid' | 'list' | 'calendar';
|
||||
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed';
|
||||
type Collection = 'all' | 'recent' | 'this-week' | 'this-month' | 'favorites' | 'failed' | 'duplicates';
|
||||
|
||||
const collections: { id: Collection; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'all', label: 'All Archives', icon: <FolderOpen className="w-4 h-4" /> },
|
||||
@@ -613,6 +657,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
|
||||
{ id: 'this-month', label: 'This Month', icon: <Calendar className="w-4 h-4" /> },
|
||||
{ id: 'favorites', label: 'Favorites', icon: <Star className="w-4 h-4" /> },
|
||||
{ id: 'failed', label: 'Failed Prints', icon: <AlertCircle className="w-4 h-4" /> },
|
||||
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
export function ArchivesPage() {
|
||||
@@ -699,6 +744,9 @@ export function ArchivesPage() {
|
||||
case 'failed':
|
||||
matchesCollection = a.status === 'failed';
|
||||
break;
|
||||
case 'duplicates':
|
||||
matchesCollection = a.duplicate_count > 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// Search filter
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
RefreshCw,
|
||||
Box,
|
||||
HardDrive,
|
||||
AlertTriangle,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { Printer, PrinterCreate } from '../api/client';
|
||||
@@ -18,6 +20,7 @@ import { Card, CardContent } from '../components/Card';
|
||||
import { Button } from '../components/Button';
|
||||
import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { FileManagerModal } from '../components/FileManagerModal';
|
||||
import { MQTTDebugModal } from '../components/MQTTDebugModal';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
@@ -74,11 +77,12 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
|
||||
);
|
||||
}
|
||||
|
||||
function PrinterCard({ printer }: { printer: Printer }) {
|
||||
function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIfDisconnected?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [showMQTTDebug, setShowMQTTDebug] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', printer.id],
|
||||
@@ -86,6 +90,9 @@ function PrinterCard({ printer }: { printer: Printer }) {
|
||||
refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
|
||||
});
|
||||
|
||||
// Determine if this card should be hidden
|
||||
const shouldHide = hideIfDisconnected && status && !status.connected;
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.deletePrinter(printer.id),
|
||||
onSuccess: () => {
|
||||
@@ -100,6 +107,10 @@ function PrinterCard({ printer }: { printer: Printer }) {
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldHide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="relative">
|
||||
<CardContent>
|
||||
@@ -124,6 +135,28 @@ function PrinterCard({ printer }: { printer: Printer }) {
|
||||
)}
|
||||
{status?.connected ? 'Connected' : 'Offline'}
|
||||
</span>
|
||||
{/* HMS Status Indicator */}
|
||||
{status?.connected && (
|
||||
<span
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs ${
|
||||
status.hms_errors && status.hms_errors.length > 0
|
||||
? status.hms_errors.some(e => e.severity <= 2)
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: 'bg-orange-500/20 text-orange-400'
|
||||
: 'bg-bambu-green/20 text-bambu-green'
|
||||
}`}
|
||||
title={
|
||||
status.hms_errors && status.hms_errors.length > 0
|
||||
? `${status.hms_errors.length} HMS error(s)`
|
||||
: 'No HMS errors'
|
||||
}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{status.hms_errors && status.hms_errors.length > 0
|
||||
? status.hms_errors.length
|
||||
: 'OK'}
|
||||
</span>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -144,6 +177,16 @@ function PrinterCard({ printer }: { printer: Printer }) {
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setShowMQTTDebug(true);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
MQTT Debug
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark-tertiary flex items-center gap-2"
|
||||
onClick={() => {
|
||||
@@ -282,6 +325,15 @@ function PrinterCard({ printer }: { printer: Printer }) {
|
||||
onClose={() => setShowFileManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MQTT Debug Modal */}
|
||||
{showMQTTDebug && (
|
||||
<MQTTDebugModal
|
||||
printerId={printer.id}
|
||||
printerName={printer.name}
|
||||
onClose={() => setShowMQTTDebug(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -429,6 +481,9 @@ function AddPrinterModal({
|
||||
|
||||
export function PrintersPage() {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [hideDisconnected, setHideDisconnected] = useState(() => {
|
||||
return localStorage.getItem('hideDisconnectedPrinters') === 'true';
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: printers, isLoading } = useQuery({
|
||||
@@ -444,6 +499,12 @@ export function PrintersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const toggleHideDisconnected = () => {
|
||||
const newValue = !hideDisconnected;
|
||||
setHideDisconnected(newValue);
|
||||
localStorage.setItem('hideDisconnectedPrinters', String(newValue));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
@@ -451,10 +512,21 @@ export function PrintersPage() {
|
||||
<h1 className="text-2xl font-bold text-white">Printers</h1>
|
||||
<p className="text-bambu-gray">Manage your Bambu Lab printers</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Printer
|
||||
</Button>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideDisconnected}
|
||||
onChange={toggleHideDisconnected}
|
||||
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
|
||||
/>
|
||||
Hide offline
|
||||
</label>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Printer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -472,7 +544,7 @@ export function PrintersPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{printers?.map((printer) => (
|
||||
<PrinterCard key={printer.id} printer={printer} />
|
||||
<PrinterCard key={printer.id} printer={printer} hideIfDisconnected={hideDisconnected} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<AppSettings | null>(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<SmartPlug | null>(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 = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
if (localSettings) {
|
||||
setLocalSettings({ ...localSettings, [key]: value });
|
||||
@@ -89,29 +83,19 @@ export function SettingsPage() {
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="text-bambu-gray">Configure Bambusy</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : showSaved ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{showSaved ? 'Saved!' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : showSaved ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
{showSaved ? 'Saved!' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{updateMutation.isError && (
|
||||
@@ -120,119 +104,171 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Archive Settings</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-8">
|
||||
{/* Left Column - General Settings */}
|
||||
<div className="space-y-6 flex-1 max-w-xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Archive Settings</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Auto-archive prints</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Automatically save 3MF files when prints complete
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.auto_archive}
|
||||
onChange={(e) => updateSetting('auto_archive', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Save thumbnails</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Extract and save preview images from 3MF files
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.save_thumbnails}
|
||||
onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-white">Auto-archive prints</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Automatically save 3MF files when prints complete
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Default filament cost (per kg)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={localSettings.default_filament_cost}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Currency</label>
|
||||
<select
|
||||
value={localSettings.currency}
|
||||
onChange={(e) => updateSetting('currency', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
<option value="CHF">CHF (Fr.)</option>
|
||||
<option value="JPY">JPY (¥)</option>
|
||||
<option value="CNY">CNY (¥)</option>
|
||||
<option value="CAD">CAD ($)</option>
|
||||
<option value="AUD">AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">About</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-white">Bambusy v0.1.2</p>
|
||||
<p className="text-bambu-gray">
|
||||
Archive and manage your Bambu Lab 3MF files
|
||||
</p>
|
||||
<p className="text-bambu-gray">
|
||||
Connect to printers via LAN mode (developer mode required)
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.auto_archive}
|
||||
onChange={(e) => updateSetting('auto_archive', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-white">Save thumbnails</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
Extract and save preview images from 3MF files
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Smart Plugs */}
|
||||
<div className="w-96 flex-shrink-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plug className="w-5 h-5 text-bambu-green" />
|
||||
<h2 className="text-lg font-semibold text-white">Smart Plugs</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingPlug(null);
|
||||
setShowPlugModal(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.save_thumbnails}
|
||||
onChange={(e) => updateSetting('save_thumbnails', e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">Cost Tracking</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">
|
||||
Default filament cost (per kg)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={localSettings.default_filament_cost}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">Currency</label>
|
||||
<select
|
||||
value={localSettings.currency}
|
||||
onChange={(e) => updateSetting('currency', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
>
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
<option value="CHF">CHF (Fr.)</option>
|
||||
<option value="JPY">JPY (¥)</option>
|
||||
<option value="CNY">CNY (¥)</option>
|
||||
<option value="CAD">CAD ($)</option>
|
||||
<option value="AUD">AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-white">About</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-white">Bambusy v0.1.0</p>
|
||||
<p className="text-bambu-gray">
|
||||
Archive and manage your Bambu Lab 3MF files
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-bambu-gray mb-4">
|
||||
Connect Tasmota-based smart plugs to automate power control for your printers.
|
||||
</p>
|
||||
<p className="text-bambu-gray">
|
||||
Connect to printers via LAN mode (developer mode required)
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{plugsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
|
||||
</div>
|
||||
) : smartPlugs && smartPlugs.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{smartPlugs.map((plug) => (
|
||||
<SmartPlugCard
|
||||
key={plug.id}
|
||||
plug={plug}
|
||||
onEdit={(p) => {
|
||||
setEditingPlug(p);
|
||||
setShowPlugModal(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<Plug className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No smart plugs configured</p>
|
||||
<p className="text-sm mt-1">Add a Tasmota plug to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Confirmation Modal */}
|
||||
{showResetConfirm && (
|
||||
<ConfirmModal
|
||||
title="Reset Settings"
|
||||
message="Reset all settings to defaults? This cannot be undone."
|
||||
confirmText="Reset"
|
||||
variant="danger"
|
||||
onConfirm={() => {
|
||||
resetMutation.mutate();
|
||||
setShowResetConfirm(false);
|
||||
{/* Smart Plug Modal */}
|
||||
{showPlugModal && (
|
||||
<AddSmartPlugModal
|
||||
plug={editingPlug}
|
||||
onClose={() => {
|
||||
setShowPlugModal(false);
|
||||
setEditingPlug(null);
|
||||
}}
|
||||
onCancel={() => setShowResetConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string, number> | null;
|
||||
} | undefined;
|
||||
printerMap: Map<string, string>;
|
||||
}) {
|
||||
const accuracy = stats?.average_time_accuracy;
|
||||
|
||||
if (accuracy === null || accuracy === undefined) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative w-28 h-28">
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle cx="56" cy="56" r="48" fill="none" stroke="#3d3d3d" strokeWidth="10" />
|
||||
<circle
|
||||
cx="56"
|
||||
cy="56"
|
||||
r="48"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${normalizedForGauge * 3.02} 302`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-xl font-bold text-white">{accuracy.toFixed(0)}%</span>
|
||||
<span className={`text-xs ${deviation >= 0 ? 'text-blue-400' : 'text-orange-400'}`}>
|
||||
{deviation >= 0 ? '+' : ''}{deviation.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-2 text-xs text-bambu-gray">
|
||||
<Target className="w-3 h-3" />
|
||||
<span>100% = perfect estimate</span>
|
||||
</div>
|
||||
{stats?.time_accuracy_by_printer && Object.keys(stats.time_accuracy_by_printer).length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{Object.entries(stats.time_accuracy_by_printer).slice(0, 3).map(([printerId, acc]) => (
|
||||
<div key={printerId} className="flex items-center justify-between text-xs">
|
||||
<span className="text-bambu-gray truncate max-w-[100px]">
|
||||
{printerMap.get(printerId) || `Printer ${printerId}`}
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
acc >= 95 && acc <= 105 ? 'text-bambu-green' :
|
||||
acc > 105 ? 'text-blue-400' : 'text-orange-400'
|
||||
}`}>
|
||||
{acc.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilamentTypesWidget({
|
||||
stats,
|
||||
}: {
|
||||
@@ -253,6 +338,12 @@ export function StatsPage() {
|
||||
component: <SuccessRateWidget stats={stats} />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: 'time-accuracy',
|
||||
title: 'Time Accuracy',
|
||||
component: <TimeAccuracyWidget stats={stats} printerMap={printerMap} />,
|
||||
defaultSize: 1,
|
||||
},
|
||||
{
|
||||
id: 'filament-types',
|
||||
title: 'Filament Types',
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 995 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-B5KMHzxr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-h3ik9UJ6.css">
|
||||
<script type="module" crossorigin src="/assets/index-BPRATuOd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DUX4pLTn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||