Merge pull request #1 from maziggy/0.1.2

0.1.2 - Lots of new features
This commit is contained in:
MartinNYHC
2025-11-28 14:10:30 +01:00
committed by GitHub
50 changed files with 13332 additions and 8639 deletions
+99
View File
@@ -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
+262 -2
View File
@@ -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"},
)
+77
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import json
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
+211
View File
@@ -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"),
}
+9
View File
@@ -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
+21 -2
View File
@@ -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)
+7
View File
@@ -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"]
+1
View File
@@ -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))
+4
View File
@@ -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
+50
View File
@@ -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
+43
View File
@@ -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",
]
+68 -1
View File
@@ -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
+7
View File
@@ -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] = []
+62
View File
@@ -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
+300 -1
View File
@@ -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"),
+89
View File
@@ -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
+31 -1
View File
@@ -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:
+250
View 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()
+195
View File
@@ -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()
+832 -6
View File
@@ -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",
+8
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+184
View File
@@ -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>
);
}
+39 -17
View File
@@ -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>
+229
View File
@@ -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>
);
}
+191
View File
@@ -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>
);
}
+296
View File
@@ -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)}
/>
)}
</>
);
}
+52 -4
View File
@@ -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
+78 -6
View File
@@ -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>
)}
+181 -145
View File
@@ -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>
+91
View File
@@ -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',
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+2 -2
View File
@@ -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>