diff --git a/README.md b/README.md
index 0df94a479..10d3883c9 100644
--- a/README.md
+++ b/README.md
@@ -45,16 +45,18 @@
### 📦 Print Archive
- Automatic 3MF archiving with metadata
- 3D model preview (Three.js)
-- Duplicate detection
+- Duplicate detection & full-text search
- Photo attachments & failure analysis
- Re-print to any connected printer
+- Archive comparison (side-by-side diff)
### 📊 Monitoring & Stats
- Real-time printer status via WebSocket
- HMS error monitoring
- Print success rates & trends
- Filament usage tracking
-- Cost analytics
+- Cost analytics & failure analysis
+- CSV/Excel export
### ⏰ Scheduling & Automation
- Print queue with drag-and-drop
@@ -63,6 +65,12 @@
- Auto power-on before print
- Auto power-off after cooldown
+### 📁 Projects
+- Group related prints (e.g., "Voron Build")
+- Track progress with target counts
+- Color-coded project badges
+- Assign archives via context menu
+
@@ -78,6 +86,7 @@
- Bambu Cloud profile management
- K-profiles (pressure advance)
- External sidebar links
+- Webhooks & API keys
### 🛠️ Maintenance
- Maintenance scheduling & tracking
diff --git a/backend/app/api/routes/api_keys.py b/backend/app/api/routes/api_keys.py
new file mode 100644
index 000000000..eb3a2493f
--- /dev/null
+++ b/backend/app/api/routes/api_keys.py
@@ -0,0 +1,138 @@
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.schemas.api_key import (
+ APIKeyCreate,
+ APIKeyUpdate,
+ APIKeyResponse,
+ APIKeyCreateResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api-keys", tags=["api-keys"])
+
+
+@router.get("/", response_model=list[APIKeyResponse])
+async def list_api_keys(db: AsyncSession = Depends(get_db)):
+ """List all API keys (without full key values)."""
+ result = await db.execute(
+ select(APIKey).order_by(APIKey.created_at.desc())
+ )
+ return list(result.scalars().all())
+
+
+@router.post("/", response_model=APIKeyCreateResponse)
+async def create_api_key(
+ data: APIKeyCreate,
+ db: AsyncSession = Depends(get_db),
+):
+ """Create a new API key.
+
+ IMPORTANT: The full API key is only returned in this response.
+ Store it securely - it cannot be retrieved again.
+ """
+ # Generate the key
+ full_key, key_hash, key_prefix = generate_api_key()
+
+ api_key = APIKey(
+ name=data.name,
+ key_hash=key_hash,
+ key_prefix=key_prefix,
+ can_queue=data.can_queue,
+ can_control_printer=data.can_control_printer,
+ can_read_status=data.can_read_status,
+ printer_ids=data.printer_ids,
+ expires_at=data.expires_at,
+ )
+ db.add(api_key)
+ await db.flush()
+ await db.refresh(api_key)
+
+ # Return with full key (only time it's shown)
+ return APIKeyCreateResponse(
+ id=api_key.id,
+ name=api_key.name,
+ key_prefix=api_key.key_prefix,
+ key=full_key, # Only returned on creation
+ can_queue=api_key.can_queue,
+ can_control_printer=api_key.can_control_printer,
+ can_read_status=api_key.can_read_status,
+ printer_ids=api_key.printer_ids,
+ enabled=api_key.enabled,
+ last_used=api_key.last_used,
+ created_at=api_key.created_at,
+ expires_at=api_key.expires_at,
+ )
+
+
+@router.get("/{key_id}", response_model=APIKeyResponse)
+async def get_api_key(
+ key_id: int,
+ db: AsyncSession = Depends(get_db),
+):
+ """Get an API key by ID."""
+ result = await db.execute(select(APIKey).where(APIKey.id == key_id))
+ api_key = result.scalar_one_or_none()
+
+ if not api_key:
+ raise HTTPException(status_code=404, detail="API key not found")
+
+ return api_key
+
+
+@router.patch("/{key_id}", response_model=APIKeyResponse)
+async def update_api_key(
+ key_id: int,
+ data: APIKeyUpdate,
+ db: AsyncSession = Depends(get_db),
+):
+ """Update an API key."""
+ result = await db.execute(select(APIKey).where(APIKey.id == key_id))
+ api_key = result.scalar_one_or_none()
+
+ if not api_key:
+ raise HTTPException(status_code=404, detail="API key not found")
+
+ # Update fields if provided
+ if data.name is not None:
+ api_key.name = data.name
+ if data.can_queue is not None:
+ api_key.can_queue = data.can_queue
+ if data.can_control_printer is not None:
+ api_key.can_control_printer = data.can_control_printer
+ if data.can_read_status is not None:
+ api_key.can_read_status = data.can_read_status
+ if data.printer_ids is not None:
+ api_key.printer_ids = data.printer_ids
+ if data.enabled is not None:
+ api_key.enabled = data.enabled
+ if data.expires_at is not None:
+ api_key.expires_at = data.expires_at
+
+ await db.flush()
+ await db.refresh(api_key)
+
+ return api_key
+
+
+@router.delete("/{key_id}")
+async def delete_api_key(
+ key_id: int,
+ db: AsyncSession = Depends(get_db),
+):
+ """Delete (revoke) an API key."""
+ result = await db.execute(select(APIKey).where(APIKey.id == key_id))
+ api_key = result.scalar_one_or_none()
+
+ if not api_key:
+ raise HTTPException(status_code=404, detail="API key not found")
+
+ await db.delete(api_key)
+
+ return {"message": "API key deleted"}
diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py
index 52704e263..deec190ee 100644
--- a/backend/app/api/routes/archives.py
+++ b/backend/app/api/routes/archives.py
@@ -54,6 +54,8 @@ def archive_to_response(
data = {
"id": archive.id,
"printer_id": archive.printer_id,
+ "project_id": archive.project_id,
+ "project_name": archive.project.name if archive.project else None,
"filename": archive.filename,
"file_path": archive.file_path,
"file_size": archive.file_size,
@@ -98,6 +100,7 @@ def archive_to_response(
@router.get("/", response_model=list[ArchiveResponse])
async def list_archives(
printer_id: int | None = None,
+ project_id: int | None = None,
limit: int = 50,
offset: int = 0,
db: AsyncSession = Depends(get_db),
@@ -106,6 +109,7 @@ async def list_archives(
service = ArchiveService(db)
archives = await service.list_archives(
printer_id=printer_id,
+ project_id=project_id,
limit=limit,
offset=offset,
)
@@ -121,6 +125,286 @@ async def list_archives(
return result
+@router.get("/search", response_model=list[ArchiveResponse])
+async def search_archives(
+ q: str = Query(..., min_length=2, description="Search query"),
+ printer_id: int | None = None,
+ project_id: int | None = None,
+ status: str | None = None,
+ limit: int = 50,
+ offset: int = 0,
+ db: AsyncSession = Depends(get_db),
+):
+ """Full-text search across archives.
+
+ Searches print_name, filename, tags, notes, designer, and filament_type fields.
+ Supports partial matches with wildcards (e.g., 'vor*' matches 'voron').
+ """
+ from sqlalchemy import text
+ from sqlalchemy.orm import selectinload
+
+ # Prepare search query - add wildcard for partial matches
+ search_term = q.strip()
+ if not search_term.endswith('*'):
+ search_term = f"{search_term}*"
+
+ # Build the FTS query
+ # Using MATCH for FTS5 full-text search
+ fts_query = text("""
+ SELECT rowid FROM archive_fts
+ WHERE archive_fts MATCH :search_term
+ ORDER BY rank
+ LIMIT :limit OFFSET :offset
+ """)
+
+ try:
+ result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
+ matched_ids = [row[0] for row in result.fetchall()]
+ except Exception as e:
+ logger.warning(f"FTS search failed, falling back to LIKE search: {e}")
+ # Fallback to LIKE search if FTS fails
+ like_pattern = f"%{q}%"
+ query = (
+ select(PrintArchive)
+ .options(selectinload(PrintArchive.project))
+ .where(
+ (PrintArchive.print_name.ilike(like_pattern)) |
+ (PrintArchive.filename.ilike(like_pattern)) |
+ (PrintArchive.tags.ilike(like_pattern)) |
+ (PrintArchive.notes.ilike(like_pattern)) |
+ (PrintArchive.designer.ilike(like_pattern)) |
+ (PrintArchive.filament_type.ilike(like_pattern))
+ )
+ .order_by(PrintArchive.created_at.desc())
+ )
+
+ if printer_id:
+ query = query.where(PrintArchive.printer_id == printer_id)
+ if project_id:
+ query = query.where(PrintArchive.project_id == project_id)
+ if status:
+ query = query.where(PrintArchive.status == status)
+
+ query = query.limit(limit).offset(offset)
+ result = await db.execute(query)
+ archives = result.scalars().all()
+ return [archive_to_response(a) for a in archives]
+
+ if not matched_ids:
+ return []
+
+ # Fetch full archive records for matched IDs
+ query = (
+ select(PrintArchive)
+ .options(selectinload(PrintArchive.project))
+ .where(PrintArchive.id.in_(matched_ids))
+ )
+
+ # Apply additional filters
+ if printer_id:
+ query = query.where(PrintArchive.printer_id == printer_id)
+ if project_id:
+ query = query.where(PrintArchive.project_id == project_id)
+ if status:
+ query = query.where(PrintArchive.status == status)
+
+ result = await db.execute(query)
+ archives_dict = {a.id: a for a in result.scalars().all()}
+
+ # Preserve FTS ranking order and apply pagination
+ ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]
+ paginated = ordered_archives[offset:offset + limit]
+
+ return [archive_to_response(a) for a in paginated]
+
+
+@router.post("/search/rebuild-index")
+async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
+ """Rebuild the full-text search index from existing archives.
+
+ Use this if search results seem incomplete or incorrect.
+ """
+ from sqlalchemy import text
+
+ try:
+ # Clear and rebuild the FTS index
+ await db.execute(text("DELETE FROM archive_fts"))
+
+ # Repopulate from print_archives
+ await db.execute(text("""
+ INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
+ SELECT id, print_name, filename, tags, notes, designer, filament_type
+ FROM print_archives
+ """))
+
+ await db.commit()
+
+ # Count entries
+ result = await db.execute(text("SELECT COUNT(*) FROM archive_fts"))
+ count = result.scalar() or 0
+
+ return {"message": f"Search index rebuilt with {count} entries"}
+ except Exception as e:
+ logger.error(f"Failed to rebuild search index: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
+
+
+@router.get("/analysis/failures")
+async def analyze_failures(
+ days: int = 30,
+ printer_id: int | None = None,
+ project_id: int | None = None,
+ db: AsyncSession = Depends(get_db),
+):
+ """Analyze failure patterns across prints.
+
+ Returns failure statistics including:
+ - Overall failure rate
+ - Failures by reason, filament type, printer
+ - Time of day distribution
+ - Recent failures
+ - Weekly trend
+ """
+ from backend.app.services.failure_analysis import FailureAnalysisService
+
+ service = FailureAnalysisService(db)
+ return await service.analyze_failures(
+ days=days,
+ printer_id=printer_id,
+ project_id=project_id,
+ )
+
+
+@router.get("/compare")
+async def compare_archives(
+ archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
+ db: AsyncSession = Depends(get_db),
+):
+ """Compare multiple archives side by side.
+
+ Compares print settings, filament usage, and print times.
+ Also analyzes correlation between settings and success/failure.
+
+ Args:
+ archive_ids: Comma-separated list of 2-5 archive IDs to compare
+ """
+ from backend.app.services.archive_comparison import ArchiveComparisonService
+
+ # Parse and validate archive IDs
+ try:
+ ids = [int(id.strip()) for id in archive_ids.split(",")]
+ except ValueError:
+ raise HTTPException(400, "Invalid archive IDs format")
+
+ if len(ids) < 2:
+ raise HTTPException(400, "At least 2 archives required for comparison")
+ if len(ids) > 5:
+ raise HTTPException(400, "Maximum 5 archives can be compared at once")
+
+ service = ArchiveComparisonService(db)
+ try:
+ return await service.compare_archives(ids)
+ except ValueError as e:
+ raise HTTPException(400, str(e))
+
+
+@router.get("/export")
+async def export_archives(
+ format: str = Query("csv", description="Export format: csv or xlsx"),
+ fields: str | None = Query(None, description="Comma-separated field names"),
+ printer_id: int | None = None,
+ project_id: int | None = None,
+ status: str | None = None,
+ date_from: str | None = Query(None, description="Start date (ISO format)"),
+ date_to: str | None = Query(None, description="End date (ISO format)"),
+ search: str | None = None,
+ db: AsyncSession = Depends(get_db),
+):
+ """Export archives to CSV or Excel format.
+
+ Returns a downloadable file with archive data.
+ """
+ from datetime import datetime
+ from fastapi.responses import StreamingResponse
+ from backend.app.services.export import ExportService
+
+ if format not in ("csv", "xlsx"):
+ raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
+
+ # Parse fields
+ field_list = None
+ if fields:
+ field_list = [f.strip() for f in fields.split(",")]
+
+ # Parse dates
+ date_from_dt = None
+ date_to_dt = None
+ if date_from:
+ try:
+ date_from_dt = datetime.fromisoformat(date_from)
+ except ValueError:
+ raise HTTPException(400, "Invalid date_from format")
+ if date_to:
+ try:
+ date_to_dt = datetime.fromisoformat(date_to)
+ except ValueError:
+ raise HTTPException(400, "Invalid date_to format")
+
+ service = ExportService(db)
+ try:
+ file_bytes, filename, content_type = await service.export_archives(
+ format=format,
+ fields=field_list,
+ printer_id=printer_id,
+ project_id=project_id,
+ status=status,
+ date_from=date_from_dt,
+ date_to=date_to_dt,
+ search=search,
+ )
+ except ImportError as e:
+ raise HTTPException(500, str(e))
+
+ return StreamingResponse(
+ io.BytesIO(file_bytes),
+ media_type=content_type,
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
+@router.get("/stats/export")
+async def export_stats(
+ format: str = Query("csv", description="Export format: csv or xlsx"),
+ days: int = 30,
+ printer_id: int | None = None,
+ project_id: int | None = None,
+ db: AsyncSession = Depends(get_db),
+):
+ """Export statistics summary to CSV or Excel format."""
+ from fastapi.responses import StreamingResponse
+ from backend.app.services.export import ExportService
+
+ if format not in ("csv", "xlsx"):
+ raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
+
+ service = ExportService(db)
+ try:
+ file_bytes, filename, content_type = await service.export_stats(
+ format=format,
+ days=days,
+ printer_id=printer_id,
+ project_id=project_id,
+ )
+ except ImportError as e:
+ raise HTTPException(500, str(e))
+
+ return StreamingResponse(
+ io.BytesIO(file_bytes),
+ media_type=content_type,
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+ )
+
+
@router.get("/stats", response_model=ArchiveStats)
async def get_archive_stats(db: AsyncSession = Depends(get_db)):
"""Get statistics across all archives."""
@@ -279,15 +563,41 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
return archive_to_response(archive, duplicates)
+@router.get("/{archive_id}/similar")
+async def find_similar_archives(
+ archive_id: int,
+ limit: int = 10,
+ db: AsyncSession = Depends(get_db),
+):
+ """Find archives with similar settings for comparison.
+
+ Returns archives that match by:
+ - Same print name (highest priority)
+ - Same file content hash
+ - Same filament type
+ """
+ from backend.app.services.archive_comparison import ArchiveComparisonService
+
+ service = ArchiveComparisonService(db)
+ try:
+ return await service.find_similar_archives(archive_id, limit=limit)
+ except ValueError as e:
+ raise HTTPException(404, str(e))
+
+
@router.patch("/{archive_id}", response_model=ArchiveResponse)
async def update_archive(
archive_id: int,
update_data: ArchiveUpdate,
db: AsyncSession = Depends(get_db),
):
- """Update archive metadata (tags, notes, cost, is_favorite)."""
+ """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
+ from sqlalchemy.orm import selectinload
+
result = await db.execute(
- select(PrintArchive).where(PrintArchive.id == archive_id)
+ select(PrintArchive)
+ .options(selectinload(PrintArchive.project))
+ .where(PrintArchive.id == archive_id)
)
archive = result.scalar_one_or_none()
if not archive:
@@ -297,8 +607,16 @@ async def update_archive(
setattr(archive, field, value)
await db.commit()
- await db.refresh(archive)
- return archive
+
+ # Re-fetch with project relationship loaded after commit
+ result = await db.execute(
+ select(PrintArchive)
+ .options(selectinload(PrintArchive.project))
+ .where(PrintArchive.id == archive_id)
+ )
+ archive = result.scalar_one_or_none()
+
+ return archive_to_response(archive)
@router.post("/{archive_id}/favorite", response_model=ArchiveResponse)
diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py
new file mode 100644
index 000000000..ac360360e
--- /dev/null
+++ b/backend/app/api/routes/projects.py
@@ -0,0 +1,428 @@
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+
+from backend.app.core.database import get_db
+from backend.app.models.project import Project
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.schemas.project import (
+ ProjectCreate,
+ ProjectUpdate,
+ ProjectResponse,
+ ProjectListResponse,
+ ProjectStats,
+ BatchAddArchives,
+ BatchAddQueueItems,
+ ArchivePreview,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/projects", tags=["projects"])
+
+
+async def compute_project_stats(
+ db: AsyncSession, project_id: int, target_count: int | None = None
+) -> ProjectStats:
+ """Compute statistics for a project."""
+ # Count total archives
+ total_result = await db.execute(
+ select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)
+ )
+ total_archives = total_result.scalar() or 0
+
+ # Count completed archives
+ completed_result = await db.execute(
+ select(func.count(PrintArchive.id)).where(
+ PrintArchive.project_id == project_id,
+ PrintArchive.status == "completed"
+ )
+ )
+ completed_prints = completed_result.scalar() or 0
+
+ # Count failed archives
+ failed_result = await db.execute(
+ select(func.count(PrintArchive.id)).where(
+ PrintArchive.project_id == project_id,
+ PrintArchive.status == "failed"
+ )
+ )
+ failed_prints = failed_result.scalar() or 0
+
+ # Sum print time and filament
+ sums_result = await db.execute(
+ select(
+ func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
+ func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
+ ).where(PrintArchive.project_id == project_id)
+ )
+ sums = sums_result.first()
+
+ # Count queued items
+ queued_result = await db.execute(
+ select(func.count(PrintQueueItem.id)).where(
+ PrintQueueItem.project_id == project_id,
+ PrintQueueItem.status == "pending"
+ )
+ )
+ queued_prints = queued_result.scalar() or 0
+
+ # Count in-progress items
+ in_progress_result = await db.execute(
+ select(func.count(PrintQueueItem.id)).where(
+ PrintQueueItem.project_id == project_id,
+ PrintQueueItem.status == "printing"
+ )
+ )
+ in_progress_prints = in_progress_result.scalar() or 0
+
+ # Calculate progress
+ progress_percent = None
+ if target_count and target_count > 0:
+ progress_percent = round((completed_prints / target_count) * 100, 1)
+
+ return ProjectStats(
+ total_archives=total_archives,
+ completed_prints=completed_prints,
+ failed_prints=failed_prints,
+ queued_prints=queued_prints,
+ in_progress_prints=in_progress_prints,
+ total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
+ total_filament_grams=round(sums.total_filament or 0, 2),
+ progress_percent=progress_percent,
+ )
+
+
+@router.get("", response_model=list[ProjectListResponse])
+@router.get("/", response_model=list[ProjectListResponse])
+async def list_projects(
+ status: str | None = None,
+ db: AsyncSession = Depends(get_db),
+):
+ """List all projects with basic stats."""
+ query = select(Project)
+ if status:
+ query = query.where(Project.status == status)
+ query = query.order_by(Project.updated_at.desc())
+
+ result = await db.execute(query)
+ projects = result.scalars().all()
+
+ # Compute quick stats for each project
+ response = []
+ for project in projects:
+ # Get archive count
+ archive_count_result = await db.execute(
+ select(func.count(PrintArchive.id)).where(
+ PrintArchive.project_id == project.id
+ )
+ )
+ archive_count = archive_count_result.scalar() or 0
+
+ # Get queue count
+ queue_count_result = await db.execute(
+ select(func.count(PrintQueueItem.id)).where(
+ PrintQueueItem.project_id == project.id,
+ PrintQueueItem.status.in_(["pending", "printing"]),
+ )
+ )
+ queue_count = queue_count_result.scalar() or 0
+
+ # Get completed count for progress
+ completed_result = await db.execute(
+ select(func.count(PrintArchive.id)).where(
+ PrintArchive.project_id == project.id,
+ PrintArchive.status == "completed",
+ )
+ )
+ completed_count = completed_result.scalar() or 0
+
+ progress_percent = None
+ if project.target_count and project.target_count > 0:
+ progress_percent = round((completed_count / project.target_count) * 100, 1)
+
+ # Get archive previews (up to 6 most recent)
+ archives_result = await db.execute(
+ select(PrintArchive)
+ .where(PrintArchive.project_id == project.id)
+ .order_by(PrintArchive.created_at.desc())
+ .limit(6)
+ )
+ archives = archives_result.scalars().all()
+ archive_previews = [
+ ArchivePreview(
+ id=a.id,
+ print_name=a.print_name,
+ thumbnail_path=a.thumbnail_path,
+ status=a.status,
+ )
+ for a in archives
+ ]
+
+ response.append(
+ ProjectListResponse(
+ id=project.id,
+ name=project.name,
+ description=project.description,
+ color=project.color,
+ status=project.status,
+ target_count=project.target_count,
+ created_at=project.created_at,
+ archive_count=archive_count,
+ queue_count=queue_count,
+ progress_percent=progress_percent,
+ archives=archive_previews,
+ )
+ )
+
+ return response
+
+
+@router.post("/", response_model=ProjectResponse)
+async def create_project(
+ data: ProjectCreate,
+ db: AsyncSession = Depends(get_db),
+):
+ """Create a new project."""
+ project = Project(
+ name=data.name,
+ description=data.description,
+ color=data.color,
+ target_count=data.target_count,
+ )
+ db.add(project)
+ await db.flush()
+ await db.refresh(project)
+
+ stats = await compute_project_stats(db, project.id, project.target_count)
+
+ return ProjectResponse(
+ id=project.id,
+ name=project.name,
+ description=project.description,
+ color=project.color,
+ status=project.status,
+ target_count=project.target_count,
+ created_at=project.created_at,
+ updated_at=project.updated_at,
+ stats=stats,
+ )
+
+
+@router.get("/{project_id}", response_model=ProjectResponse)
+async def get_project(
+ project_id: int,
+ db: AsyncSession = Depends(get_db),
+):
+ """Get a project by ID with detailed stats."""
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ project = result.scalar_one_or_none()
+
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ stats = await compute_project_stats(db, project.id, project.target_count)
+
+ return ProjectResponse(
+ id=project.id,
+ name=project.name,
+ description=project.description,
+ color=project.color,
+ status=project.status,
+ target_count=project.target_count,
+ created_at=project.created_at,
+ updated_at=project.updated_at,
+ stats=stats,
+ )
+
+
+@router.patch("/{project_id}", response_model=ProjectResponse)
+async def update_project(
+ project_id: int,
+ data: ProjectUpdate,
+ db: AsyncSession = Depends(get_db),
+):
+ """Update a project."""
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ project = result.scalar_one_or_none()
+
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Update fields if provided
+ if data.name is not None:
+ project.name = data.name
+ if data.description is not None:
+ project.description = data.description
+ if data.color is not None:
+ project.color = data.color
+ if data.status is not None:
+ if data.status not in ["active", "completed", "archived"]:
+ raise HTTPException(status_code=400, detail="Invalid status")
+ project.status = data.status
+ if data.target_count is not None:
+ project.target_count = data.target_count
+
+ await db.flush()
+ await db.refresh(project)
+
+ stats = await compute_project_stats(db, project.id, project.target_count)
+
+ return ProjectResponse(
+ id=project.id,
+ name=project.name,
+ description=project.description,
+ color=project.color,
+ status=project.status,
+ target_count=project.target_count,
+ created_at=project.created_at,
+ updated_at=project.updated_at,
+ stats=stats,
+ )
+
+
+@router.delete("/{project_id}")
+async def delete_project(
+ project_id: int,
+ db: AsyncSession = Depends(get_db),
+):
+ """Delete a project. Archives and queue items will have project_id set to NULL."""
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ project = result.scalar_one_or_none()
+
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ await db.delete(project)
+
+ return {"message": "Project deleted"}
+
+
+@router.get("/{project_id}/archives")
+async def list_project_archives(
+ project_id: int,
+ limit: int = 100,
+ offset: int = 0,
+ db: AsyncSession = Depends(get_db),
+):
+ """List archives in a project."""
+ # Verify project exists
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ if not result.scalar_one_or_none():
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Get archives
+ query = (
+ select(PrintArchive)
+ .where(PrintArchive.project_id == project_id)
+ .order_by(PrintArchive.created_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+ result = await db.execute(query)
+ archives = result.scalars().all()
+
+ # Import the response converter from archives module
+ from backend.app.api.routes.archives import archive_to_response
+
+ return [archive_to_response(a) for a in archives]
+
+
+@router.get("/{project_id}/queue")
+async def list_project_queue(
+ project_id: int,
+ db: AsyncSession = Depends(get_db),
+):
+ """List queue items in a project."""
+ # Verify project exists
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ if not result.scalar_one_or_none():
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Get queue items
+ query = (
+ select(PrintQueueItem)
+ .where(PrintQueueItem.project_id == project_id)
+ .order_by(PrintQueueItem.position)
+ )
+ result = await db.execute(query)
+ items = result.scalars().all()
+
+ return items
+
+
+@router.post("/{project_id}/add-archives")
+async def add_archives_to_project(
+ project_id: int,
+ data: BatchAddArchives,
+ db: AsyncSession = Depends(get_db),
+):
+ """Batch add archives to a project."""
+ # Verify project exists
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ if not result.scalar_one_or_none():
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Update archives
+ updated = 0
+ for archive_id in data.archive_ids:
+ result = await db.execute(
+ select(PrintArchive).where(PrintArchive.id == archive_id)
+ )
+ archive = result.scalar_one_or_none()
+ if archive:
+ archive.project_id = project_id
+ updated += 1
+
+ return {"message": f"Added {updated} archives to project"}
+
+
+@router.post("/{project_id}/add-queue")
+async def add_queue_items_to_project(
+ project_id: int,
+ data: BatchAddQueueItems,
+ db: AsyncSession = Depends(get_db),
+):
+ """Batch add queue items to a project."""
+ # Verify project exists
+ result = await db.execute(select(Project).where(Project.id == project_id))
+ if not result.scalar_one_or_none():
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Update queue items
+ updated = 0
+ for item_id in data.queue_item_ids:
+ result = await db.execute(
+ select(PrintQueueItem).where(PrintQueueItem.id == item_id)
+ )
+ item = result.scalar_one_or_none()
+ if item:
+ item.project_id = project_id
+ updated += 1
+
+ return {"message": f"Added {updated} queue items to project"}
+
+
+@router.post("/{project_id}/remove-archives")
+async def remove_archives_from_project(
+ project_id: int,
+ data: BatchAddArchives,
+ db: AsyncSession = Depends(get_db),
+):
+ """Remove archives from a project (sets project_id to NULL)."""
+ updated = 0
+ for archive_id in data.archive_ids:
+ result = await db.execute(
+ select(PrintArchive).where(
+ PrintArchive.id == archive_id,
+ PrintArchive.project_id == project_id,
+ )
+ )
+ archive = result.scalar_one_or_none()
+ if archive:
+ archive.project_id = None
+ updated += 1
+
+ return {"message": f"Removed {updated} archives from project"}
diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py
new file mode 100644
index 000000000..06c104935
--- /dev/null
+++ b/backend/app/api/routes/webhook.py
@@ -0,0 +1,332 @@
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from pydantic import BaseModel
+
+from backend.app.core.database import get_db
+from backend.app.core.auth import get_api_key, check_permission, check_printer_access
+from backend.app.models.api_key import APIKey
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/webhook", tags=["webhook"])
+
+
+# Request schemas
+class QueueAddRequest(BaseModel):
+ archive_id: int
+ printer_id: int
+ project_id: int | None = None
+ scheduled_time: str | None = None # ISO format datetime
+ require_previous_success: bool = False
+ auto_off_after: bool = False
+
+
+class QueueAddResponse(BaseModel):
+ id: int
+ archive_id: int
+ printer_id: int
+ position: int
+ status: str
+ message: str
+
+
+class PrinterStatusResponse(BaseModel):
+ id: int
+ name: str
+ connected: bool
+ state: str | None
+ current_print: str | None
+ progress: float | None
+ remaining_time: int | None
+
+
+class QueueStatusResponse(BaseModel):
+ printer_id: int
+ printer_name: str
+ pending: int
+ printing: int
+ items: list[dict]
+
+
+# Webhook endpoints
+
+@router.post("/queue/add", response_model=QueueAddResponse)
+async def webhook_add_to_queue(
+ data: QueueAddRequest,
+ api_key: APIKey = Depends(get_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Add a print to the queue via webhook.
+
+ Requires 'can_queue' permission.
+ """
+ check_permission(api_key, 'queue')
+ check_printer_access(api_key, data.printer_id)
+
+ # Verify archive exists
+ result = await db.execute(
+ select(PrintArchive).where(PrintArchive.id == data.archive_id)
+ )
+ archive = result.scalar_one_or_none()
+ if not archive:
+ raise HTTPException(status_code=404, detail="Archive not found")
+
+ # Verify printer exists
+ result = await db.execute(
+ select(Printer).where(Printer.id == data.printer_id)
+ )
+ printer = result.scalar_one_or_none()
+ if not printer:
+ raise HTTPException(status_code=404, detail="Printer not found")
+
+ # Get next position
+ result = await db.execute(
+ select(PrintQueueItem.position)
+ .where(
+ PrintQueueItem.printer_id == data.printer_id,
+ PrintQueueItem.status == "pending",
+ )
+ .order_by(PrintQueueItem.position.desc())
+ .limit(1)
+ )
+ max_position = result.scalar()
+ next_position = (max_position or 0) + 1
+
+ # Parse scheduled time if provided
+ scheduled_time = None
+ if data.scheduled_time:
+ from datetime import datetime
+ try:
+ scheduled_time = datetime.fromisoformat(data.scheduled_time.replace('Z', '+00:00'))
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
+
+ # Create queue item
+ queue_item = PrintQueueItem(
+ printer_id=data.printer_id,
+ archive_id=data.archive_id,
+ project_id=data.project_id,
+ position=next_position,
+ scheduled_time=scheduled_time,
+ require_previous_success=data.require_previous_success,
+ auto_off_after=data.auto_off_after,
+ )
+ db.add(queue_item)
+ await db.flush()
+ await db.refresh(queue_item)
+
+ return QueueAddResponse(
+ id=queue_item.id,
+ archive_id=queue_item.archive_id,
+ printer_id=queue_item.printer_id,
+ position=queue_item.position,
+ status=queue_item.status,
+ message=f"Added to queue at position {queue_item.position}",
+ )
+
+
+@router.post("/printer/{printer_id}/start")
+async def webhook_start_print(
+ printer_id: int,
+ api_key: APIKey = Depends(get_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Start the next queued print on a printer.
+
+ Requires 'can_control_printer' permission.
+ """
+ check_permission(api_key, 'control_printer')
+ check_printer_access(api_key, printer_id)
+
+ # Get printer
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
+ printer = result.scalar_one_or_none()
+ if not printer:
+ raise HTTPException(status_code=404, detail="Printer not found")
+
+ # Get next pending queue item
+ result = await db.execute(
+ select(PrintQueueItem)
+ .where(
+ PrintQueueItem.printer_id == printer_id,
+ PrintQueueItem.status == "pending",
+ )
+ .order_by(PrintQueueItem.position)
+ .limit(1)
+ )
+ queue_item = result.scalar_one_or_none()
+ if not queue_item:
+ raise HTTPException(status_code=404, detail="No pending prints in queue")
+
+ # Check if printer is ready
+ status = printer_manager.get_status(printer_id)
+ if not status or not status.get("connected"):
+ raise HTTPException(status_code=503, detail="Printer not connected")
+
+ if status.get("state") not in ["IDLE", "FINISH", "FAILED"]:
+ raise HTTPException(
+ status_code=409,
+ detail=f"Printer is busy (state: {status.get('state')})"
+ )
+
+ # Start the print
+ try:
+ await printer_manager.start_print(printer_id, queue_item.archive_id)
+ except Exception as e:
+ logger.error(f"Failed to start print: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ return {"message": "Print started", "queue_item_id": queue_item.id}
+
+
+@router.post("/printer/{printer_id}/stop")
+async def webhook_stop_print(
+ printer_id: int,
+ api_key: APIKey = Depends(get_api_key),
+):
+ """Stop the current print on a printer.
+
+ Requires 'can_control_printer' permission.
+ """
+ check_permission(api_key, 'control_printer')
+ check_printer_access(api_key, printer_id)
+
+ status = printer_manager.get_status(printer_id)
+ if not status or not status.get("connected"):
+ raise HTTPException(status_code=503, detail="Printer not connected")
+
+ if status.get("state") != "RUNNING":
+ raise HTTPException(status_code=409, detail="No print in progress")
+
+ try:
+ await printer_manager.stop_print(printer_id)
+ except Exception as e:
+ logger.error(f"Failed to stop print: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ return {"message": "Print stopped"}
+
+
+@router.post("/printer/{printer_id}/cancel")
+async def webhook_cancel_print(
+ printer_id: int,
+ api_key: APIKey = Depends(get_api_key),
+):
+ """Cancel the current print on a printer.
+
+ Requires 'can_control_printer' permission.
+ """
+ check_permission(api_key, 'control_printer')
+ check_printer_access(api_key, printer_id)
+
+ status = printer_manager.get_status(printer_id)
+ if not status or not status.get("connected"):
+ raise HTTPException(status_code=503, detail="Printer not connected")
+
+ if status.get("state") not in ["RUNNING", "PAUSE"]:
+ raise HTTPException(status_code=409, detail="No print to cancel")
+
+ try:
+ await printer_manager.cancel_print(printer_id)
+ except Exception as e:
+ logger.error(f"Failed to cancel print: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ return {"message": "Print cancelled"}
+
+
+@router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
+async def webhook_get_printer_status(
+ printer_id: int,
+ api_key: APIKey = Depends(get_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get status of a printer.
+
+ Requires 'can_read_status' permission.
+ """
+ check_permission(api_key, 'read_status')
+ check_printer_access(api_key, printer_id)
+
+ # Get printer
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
+ printer = result.scalar_one_or_none()
+ if not printer:
+ raise HTTPException(status_code=404, detail="Printer not found")
+
+ status = printer_manager.get_status(printer_id)
+
+ return PrinterStatusResponse(
+ id=printer.id,
+ name=printer.name,
+ connected=status.get("connected", False) if status else False,
+ state=status.get("state") if status else None,
+ current_print=status.get("current_print") if status else None,
+ progress=status.get("progress") if status else None,
+ remaining_time=status.get("remaining_time") if status else None,
+ )
+
+
+@router.get("/queue", response_model=list[QueueStatusResponse])
+async def webhook_get_queue_status(
+ printer_id: int | None = None,
+ api_key: APIKey = Depends(get_api_key),
+ db: AsyncSession = Depends(get_db),
+):
+ """Get queue status for all printers or a specific printer.
+
+ Requires 'can_read_status' permission.
+ """
+ check_permission(api_key, 'read_status')
+
+ # Get printers
+ if printer_id:
+ check_printer_access(api_key, printer_id)
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
+ printers = result.scalars().all()
+ else:
+ result = await db.execute(select(Printer))
+ printers = result.scalars().all()
+ # Filter by allowed printers if limited
+ if api_key.printer_ids:
+ printers = [p for p in printers if p.id in api_key.printer_ids]
+
+ response = []
+ for printer in printers:
+ # Get queue items
+ result = await db.execute(
+ select(PrintQueueItem)
+ .where(
+ PrintQueueItem.printer_id == printer.id,
+ PrintQueueItem.status.in_(["pending", "printing"]),
+ )
+ .order_by(PrintQueueItem.position)
+ )
+ items = result.scalars().all()
+
+ pending_count = sum(1 for i in items if i.status == "pending")
+ printing_count = sum(1 for i in items if i.status == "printing")
+
+ response.append(QueueStatusResponse(
+ printer_id=printer.id,
+ printer_name=printer.name,
+ pending=pending_count,
+ printing=printing_count,
+ items=[
+ {
+ "id": item.id,
+ "archive_id": item.archive_id,
+ "position": item.position,
+ "status": item.status,
+ }
+ for item in items
+ ],
+ ))
+
+ return response
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
new file mode 100644
index 000000000..9ca4b721d
--- /dev/null
+++ b/backend/app/core/auth.py
@@ -0,0 +1,114 @@
+import hashlib
+import secrets
+from datetime import datetime
+from typing import Optional
+
+from fastapi import Header, HTTPException, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.api_key import APIKey
+
+
+def generate_api_key() -> tuple[str, str, str]:
+ """Generate a new API key.
+
+ Returns:
+ Tuple of (full_key, key_hash, key_prefix)
+ """
+ # Generate a random 32-byte key and encode as hex (64 chars)
+ full_key = f"bb_{secrets.token_hex(32)}"
+ key_hash = hashlib.sha256(full_key.encode()).hexdigest()
+ key_prefix = full_key[:11] # "bb_" + first 8 chars of token
+ return full_key, key_hash, key_prefix
+
+
+def hash_api_key(key: str) -> str:
+ """Hash an API key for comparison."""
+ return hashlib.sha256(key.encode()).hexdigest()
+
+
+async def get_api_key(
+ x_api_key: str = Header(..., alias="X-API-Key"),
+ db: AsyncSession = Depends(get_db),
+) -> APIKey:
+ """Verify API key and return the key record.
+
+ Raises HTTPException if key is invalid, disabled, or expired.
+ """
+ key_hash = hash_api_key(x_api_key)
+
+ result = await db.execute(
+ select(APIKey).where(APIKey.key_hash == key_hash)
+ )
+ api_key = result.scalar_one_or_none()
+
+ if not api_key:
+ raise HTTPException(status_code=401, detail="Invalid API key")
+
+ if not api_key.enabled:
+ raise HTTPException(status_code=403, detail="API key is disabled")
+
+ if api_key.expires_at and api_key.expires_at < datetime.utcnow():
+ raise HTTPException(status_code=403, detail="API key has expired")
+
+ # Update last_used timestamp
+ api_key.last_used = datetime.utcnow()
+
+ return api_key
+
+
+async def get_optional_api_key(
+ x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
+ db: AsyncSession = Depends(get_db),
+) -> Optional[APIKey]:
+ """Get API key if provided, return None otherwise."""
+ if not x_api_key:
+ return None
+
+ try:
+ return await get_api_key(x_api_key, db)
+ except HTTPException:
+ return None
+
+
+def check_permission(api_key: APIKey, permission: str) -> None:
+ """Check if API key has a specific permission.
+
+ Args:
+ api_key: The API key record
+ permission: One of 'queue', 'control_printer', 'read_status'
+
+ Raises HTTPException if permission is denied.
+ """
+ permission_map = {
+ 'queue': api_key.can_queue,
+ 'control_printer': api_key.can_control_printer,
+ 'read_status': api_key.can_read_status,
+ }
+
+ if permission not in permission_map:
+ raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
+
+ if not permission_map[permission]:
+ raise HTTPException(
+ status_code=403,
+ detail=f"API key does not have '{permission}' permission"
+ )
+
+
+def check_printer_access(api_key: APIKey, printer_id: int) -> None:
+ """Check if API key has access to a specific printer.
+
+ Args:
+ api_key: The API key record
+ printer_id: The printer ID to check
+
+ Raises HTTPException if access is denied.
+ """
+ if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
+ raise HTTPException(
+ status_code=403,
+ detail=f"API key does not have access to printer {printer_id}"
+ )
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index 3e9d97b95..f8c85d347 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
- from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link # noqa: F401
+ from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link, project, api_key # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -191,6 +191,72 @@ async def run_migrations(conn):
except Exception:
pass
+ # Migration: Add project_id column to print_archives
+ try:
+ await conn.execute(text(
+ "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
+ ))
+ except Exception:
+ pass
+
+ # Migration: Add project_id column to print_queue
+ try:
+ await conn.execute(text(
+ "ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
+ ))
+ except Exception:
+ pass
+
+ # Migration: Create FTS5 virtual table for archive full-text search
+ try:
+ await conn.execute(text("""
+ CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
+ print_name,
+ filename,
+ tags,
+ notes,
+ designer,
+ filament_type,
+ content='print_archives',
+ content_rowid='id'
+ )
+ """))
+ except Exception:
+ pass
+
+ # Migration: Create triggers to keep FTS index in sync
+ try:
+ await conn.execute(text("""
+ CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
+ INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
+ VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
+ END
+ """))
+ except Exception:
+ pass
+
+ try:
+ await conn.execute(text("""
+ CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
+ INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
+ VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
+ END
+ """))
+ except Exception:
+ pass
+
+ try:
+ await conn.execute(text("""
+ CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
+ INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
+ VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
+ INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
+ VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
+ END
+ """))
+ except Exception:
+ pass
+
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
diff --git a/backend/app/main.py b/backend/app/main.py
index c824b8c88..778557654 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook
from backend.app.api.routes import settings as settings_routes
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
@@ -1032,6 +1032,9 @@ app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
app.include_router(camera.router, prefix=app_settings.api_prefix)
app.include_router(external_links.router, prefix=app_settings.api_prefix)
+app.include_router(projects.router, prefix=app_settings.api_prefix)
+app.include_router(api_keys.router, prefix=app_settings.api_prefix)
+app.include_router(webhook.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 2dae4e7f2..e1643ea3c 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -7,6 +7,8 @@ from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance,
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.notification_template import NotificationTemplate
from backend.app.models.notification import NotificationLog
+from backend.app.models.project import Project
+from backend.app.models.api_key import APIKey
__all__ = [
"Printer",
@@ -20,4 +22,6 @@ __all__ = [
"KProfileNote",
"NotificationTemplate",
"NotificationLog",
+ "Project",
+ "APIKey",
]
diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py
new file mode 100644
index 000000000..790e4f987
--- /dev/null
+++ b/backend/app/models/api_key.py
@@ -0,0 +1,29 @@
+from datetime import datetime
+from sqlalchemy import String, Boolean, DateTime, Text, JSON, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class APIKey(Base):
+ """API key for external webhook access."""
+
+ __tablename__ = "api_keys"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(100)) # User-friendly name
+ key_hash: Mapped[str] = mapped_column(String(64)) # SHA256 hash of the key
+ key_prefix: Mapped[str] = mapped_column(String(8)) # First 8 chars for identification
+
+ # Permissions
+ can_queue: Mapped[bool] = mapped_column(Boolean, default=True) # Add to queue
+ can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False) # Start/stop/cancel
+ can_read_status: Mapped[bool] = mapped_column(Boolean, default=True) # Query status
+
+ # Optional scope limits
+ printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) # null = all printers
+
+ enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+ last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Optional expiry
diff --git a/backend/app/models/archive.py b/backend/app/models/archive.py
index 800139567..d8971009b 100644
--- a/backend/app/models/archive.py
+++ b/backend/app/models/archive.py
@@ -10,6 +10,9 @@ class PrintArchive(Base):
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
+ project_id: Mapped[int | None] = mapped_column(
+ ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
+ )
# File info
filename: Mapped[str] = mapped_column(String(255))
@@ -63,6 +66,8 @@ class PrintArchive(Base):
# Relationships
printer: Mapped["Printer | None"] = relationship(back_populates="archives")
+ project: Mapped["Project | None"] = relationship(back_populates="archives")
from backend.app.models.printer import Printer # noqa: E402, F811
+from backend.app.models.project import Project # noqa: E402, F811
diff --git a/backend/app/models/print_queue.py b/backend/app/models/print_queue.py
index 4d0325327..1089d013b 100644
--- a/backend/app/models/print_queue.py
+++ b/backend/app/models/print_queue.py
@@ -19,6 +19,9 @@ class PrintQueueItem(Base):
archive_id: Mapped[int] = mapped_column(
ForeignKey("print_archives.id", ondelete="CASCADE")
)
+ project_id: Mapped[int | None] = mapped_column(
+ ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
+ )
# Scheduling
position: Mapped[int] = mapped_column(Integer, default=0) # Queue order
@@ -44,7 +47,9 @@ class PrintQueueItem(Base):
# Relationships
printer: Mapped["Printer"] = relationship()
archive: Mapped["PrintArchive"] = relationship()
+ project: Mapped["Project | None"] = relationship(back_populates="queue_items")
from backend.app.models.printer import Printer # noqa: E402
from backend.app.models.archive import PrintArchive # noqa: E402
+from backend.app.models.project import Project # noqa: E402
diff --git a/backend/app/models/project.py b/backend/app/models/project.py
new file mode 100644
index 000000000..f41a4a0e1
--- /dev/null
+++ b/backend/app/models/project.py
@@ -0,0 +1,32 @@
+from datetime import datetime
+from sqlalchemy import String, Integer, DateTime, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class Project(Base):
+ """Project to group related prints (e.g., 'Voron Build' with multiple parts)."""
+
+ __tablename__ = "projects"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String(255))
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ color: Mapped[str | None] = mapped_column(String(20), nullable=True) # Hex color for UI
+ status: Mapped[str] = mapped_column(String(20), default="active") # active, completed, archived
+ target_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # Optional target number of prints
+
+ # 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()
+ )
+
+ # Relationships
+ archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project")
+ queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project")
+
+
+from backend.app.models.archive import PrintArchive # noqa: E402
+from backend.app.models.print_queue import PrintQueueItem # noqa: E402
diff --git a/backend/app/schemas/api_key.py b/backend/app/schemas/api_key.py
new file mode 100644
index 000000000..92ad31cb9
--- /dev/null
+++ b/backend/app/schemas/api_key.py
@@ -0,0 +1,46 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+
+class APIKeyCreate(BaseModel):
+ """Schema for creating a new API key."""
+ name: str
+ can_queue: bool = True
+ can_control_printer: bool = False
+ can_read_status: bool = True
+ printer_ids: list[int] | None = None # null = all printers
+ expires_at: datetime | None = None
+
+
+class APIKeyUpdate(BaseModel):
+ """Schema for updating an API key."""
+ name: str | None = None
+ can_queue: bool | None = None
+ can_control_printer: bool | None = None
+ can_read_status: bool | None = None
+ printer_ids: list[int] | None = None
+ enabled: bool | None = None
+ expires_at: datetime | None = None
+
+
+class APIKeyResponse(BaseModel):
+ """Schema for API key response (without full key)."""
+ id: int
+ name: str
+ key_prefix: str # First 8 chars for identification
+ can_queue: bool
+ can_control_printer: bool
+ can_read_status: bool
+ printer_ids: list[int] | None
+ enabled: bool
+ last_used: datetime | None
+ created_at: datetime
+ expires_at: datetime | None
+
+ class Config:
+ from_attributes = True
+
+
+class APIKeyCreateResponse(APIKeyResponse):
+ """Response when creating a key - includes full key (shown only once)."""
+ key: str # Full API key, only shown on creation
diff --git a/backend/app/schemas/archive.py b/backend/app/schemas/archive.py
index 48f7ed291..9e1feb3c7 100644
--- a/backend/app/schemas/archive.py
+++ b/backend/app/schemas/archive.py
@@ -13,6 +13,7 @@ class ArchiveBase(BaseModel):
class ArchiveUpdate(ArchiveBase):
printer_id: int | None = None
+ project_id: int | None = None
class ArchiveDuplicate(BaseModel):
@@ -26,6 +27,8 @@ class ArchiveDuplicate(BaseModel):
class ArchiveResponse(BaseModel):
id: int
printer_id: int | None
+ project_id: int | None = None
+ project_name: str | None = None # Included for convenience
filename: str
file_path: str
file_size: int
diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py
new file mode 100644
index 000000000..384b3fca2
--- /dev/null
+++ b/backend/app/schemas/project.py
@@ -0,0 +1,85 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+
+class ProjectCreate(BaseModel):
+ """Schema for creating a new project."""
+ name: str
+ description: str | None = None
+ color: str | None = None
+ target_count: int | None = None
+
+
+class ProjectUpdate(BaseModel):
+ """Schema for updating a project."""
+ name: str | None = None
+ description: str | None = None
+ color: str | None = None
+ status: str | None = None # active, completed, archived
+ target_count: int | None = None
+
+
+class ProjectStats(BaseModel):
+ """Statistics for a project."""
+ total_archives: int = 0
+ completed_prints: int = 0
+ failed_prints: int = 0
+ queued_prints: int = 0
+ in_progress_prints: int = 0
+ total_print_time_hours: float = 0.0
+ total_filament_grams: float = 0.0
+ progress_percent: float | None = None # Based on target_count
+
+
+class ProjectResponse(BaseModel):
+ """Schema for project response."""
+ id: int
+ name: str
+ description: str | None
+ color: str | None
+ status: str
+ target_count: int | None
+ created_at: datetime
+ updated_at: datetime
+ stats: ProjectStats | None = None
+
+ class Config:
+ from_attributes = True
+
+
+class ArchivePreview(BaseModel):
+ """Minimal archive data for project preview."""
+ id: int
+ print_name: str | None
+ thumbnail_path: str | None
+ status: str
+
+
+class ProjectListResponse(BaseModel):
+ """Schema for project list item (lighter weight)."""
+ id: int
+ name: str
+ description: str | None
+ color: str | None
+ status: str
+ target_count: int | None
+ created_at: datetime
+ # Quick stats
+ archive_count: int = 0
+ queue_count: int = 0
+ progress_percent: float | None = None
+ # Preview of archives (up to 5)
+ archives: list[ArchivePreview] = []
+
+ class Config:
+ from_attributes = True
+
+
+class BatchAddArchives(BaseModel):
+ """Schema for batch adding archives to a project."""
+ archive_ids: list[int]
+
+
+class BatchAddQueueItems(BaseModel):
+ """Schema for batch adding queue items to a project."""
+ queue_item_ids: list[int]
diff --git a/backend/app/services/archive.py b/backend/app/services/archive.py
index a28b6f222..fa9d98474 100644
--- a/backend/app/services/archive.py
+++ b/backend/app/services/archive.py
@@ -733,15 +733,25 @@ class ArchiveService:
async def list_archives(
self,
printer_id: int | None = None,
+ project_id: int | None = None,
limit: int = 50,
offset: int = 0,
) -> list[PrintArchive]:
"""List archives with optional filtering."""
- query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
+ from sqlalchemy.orm import selectinload
+
+ query = (
+ select(PrintArchive)
+ .options(selectinload(PrintArchive.project))
+ .order_by(PrintArchive.created_at.desc())
+ )
if printer_id:
query = query.where(PrintArchive.printer_id == printer_id)
+ if project_id:
+ query = query.where(PrintArchive.project_id == project_id)
+
query = query.limit(limit).offset(offset)
result = await self.db.execute(query)
return list(result.scalars().all())
diff --git a/backend/app/services/archive_comparison.py b/backend/app/services/archive_comparison.py
new file mode 100644
index 000000000..4a855d230
--- /dev/null
+++ b/backend/app/services/archive_comparison.py
@@ -0,0 +1,278 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.archive import PrintArchive
+
+
+class ArchiveComparisonService:
+ """Service for comparing print archives."""
+
+ # Fields to compare
+ COMPARABLE_FIELDS = [
+ ("layer_height", "Layer Height", "mm"),
+ ("nozzle_diameter", "Nozzle Diameter", "mm"),
+ ("bed_temperature", "Bed Temperature", "°C"),
+ ("nozzle_temperature", "Nozzle Temperature", "°C"),
+ ("filament_type", "Filament Type", None),
+ ("filament_used_grams", "Filament Used", "g"),
+ ("print_time_seconds", "Print Time", "s"),
+ ("total_layers", "Total Layers", None),
+ ("status", "Status", None),
+ ]
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def compare_archives(self, archive_ids: list[int]) -> dict:
+ """Compare multiple archives side by side.
+
+ Args:
+ archive_ids: List of 2-5 archive IDs to compare
+
+ Returns:
+ Dictionary with comparison results
+ """
+ if len(archive_ids) < 2:
+ raise ValueError("At least 2 archives required for comparison")
+ if len(archive_ids) > 5:
+ raise ValueError("Maximum 5 archives can be compared at once")
+
+ # Fetch archives
+ result = await self.db.execute(
+ select(PrintArchive)
+ .options(selectinload(PrintArchive.project))
+ .where(PrintArchive.id.in_(archive_ids))
+ )
+ archives = {a.id: a for a in result.scalars().all()}
+
+ if len(archives) != len(archive_ids):
+ missing = set(archive_ids) - set(archives.keys())
+ raise ValueError(f"Archives not found: {missing}")
+
+ # Preserve order from input
+ ordered_archives = [archives[id] for id in archive_ids]
+
+ # Build basic info for each archive
+ archive_info = [
+ {
+ "id": a.id,
+ "print_name": a.print_name or a.filename,
+ "status": a.status,
+ "created_at": a.created_at.isoformat() if a.created_at else None,
+ "printer_id": a.printer_id,
+ "project_name": a.project.name if a.project else None,
+ }
+ for a in ordered_archives
+ ]
+
+ # Build field comparison
+ comparison = []
+ differences = []
+
+ for field_name, display_name, unit in self.COMPARABLE_FIELDS:
+ values = [getattr(a, field_name) for a in ordered_archives]
+
+ # Format values for display
+ formatted_values = []
+ for v in values:
+ if v is None:
+ formatted_values.append(None)
+ elif field_name == "print_time_seconds":
+ # Format as human-readable time
+ hours = int(v) // 3600
+ minutes = (int(v) % 3600) // 60
+ formatted_values.append(f"{hours}h {minutes}m" if hours else f"{minutes}m")
+ elif isinstance(v, float):
+ formatted_values.append(round(v, 2))
+ else:
+ formatted_values.append(v)
+
+ # Check if values differ
+ non_none_values = [v for v in values if v is not None]
+ has_difference = len(set(str(v) for v in non_none_values)) > 1 if non_none_values else False
+
+ field_data = {
+ "field": field_name,
+ "label": display_name,
+ "unit": unit,
+ "values": formatted_values,
+ "raw_values": values,
+ "has_difference": has_difference,
+ }
+
+ comparison.append(field_data)
+
+ if has_difference:
+ differences.append(field_data)
+
+ # Analyze success/failure correlation
+ success_correlation = self._analyze_success_correlation(ordered_archives)
+
+ return {
+ "archives": archive_info,
+ "comparison": comparison,
+ "differences": differences,
+ "success_correlation": success_correlation,
+ }
+
+ def _analyze_success_correlation(self, archives: list[PrintArchive]) -> dict:
+ """Analyze what settings correlate with success/failure."""
+ successful = [a for a in archives if a.status == "completed"]
+ failed = [a for a in archives if a.status == "failed"]
+
+ if not successful or not failed:
+ return {
+ "has_both_outcomes": False,
+ "message": "Need both successful and failed prints to analyze correlation",
+ }
+
+ # Find settings that differ between successful and failed
+ insights = []
+
+ for field_name, display_name, unit in self.COMPARABLE_FIELDS:
+ if field_name == "status":
+ continue
+
+ success_values = [getattr(a, field_name) for a in successful if getattr(a, field_name) is not None]
+ failed_values = [getattr(a, field_name) for a in failed if getattr(a, field_name) is not None]
+
+ if not success_values or not failed_values:
+ continue
+
+ # For numeric fields, compare averages
+ if isinstance(success_values[0], (int, float)):
+ success_avg = sum(success_values) / len(success_values)
+ failed_avg = sum(failed_values) / len(failed_values)
+
+ if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01):
+ direction = "higher" if success_avg > failed_avg else "lower"
+ insights.append({
+ "field": field_name,
+ "label": display_name,
+ "success_avg": round(success_avg, 2),
+ "failed_avg": round(failed_avg, 2),
+ "insight": f"Successful prints had {direction} {display_name}",
+ })
+ else:
+ # For categorical fields, check if success uses different values
+ success_set = set(str(v) for v in success_values)
+ failed_set = set(str(v) for v in failed_values)
+
+ if success_set != failed_set:
+ insights.append({
+ "field": field_name,
+ "label": display_name,
+ "success_values": list(success_set),
+ "failed_values": list(failed_set),
+ "insight": f"Different {display_name} used in successful vs failed prints",
+ })
+
+ return {
+ "has_both_outcomes": True,
+ "successful_count": len(successful),
+ "failed_count": len(failed),
+ "insights": insights,
+ }
+
+ async def find_similar_archives(
+ self,
+ archive_id: int,
+ limit: int = 10,
+ ) -> list[dict]:
+ """Find archives with similar settings for comparison.
+
+ Args:
+ archive_id: The archive to find similar ones for
+ limit: Maximum number of results
+
+ Returns:
+ List of similar archives with match reasons
+ """
+ # Get the reference archive
+ result = await self.db.execute(
+ select(PrintArchive).where(PrintArchive.id == archive_id)
+ )
+ reference = result.scalar_one_or_none()
+
+ if not reference:
+ raise ValueError("Archive not found")
+
+ # Find similar archives
+ similar = []
+
+ # By same print name
+ if reference.print_name:
+ result = await self.db.execute(
+ select(PrintArchive)
+ .where(
+ PrintArchive.id != archive_id,
+ PrintArchive.print_name == reference.print_name,
+ )
+ .order_by(PrintArchive.created_at.desc())
+ .limit(limit)
+ )
+ for a in result.scalars().all():
+ similar.append({
+ "archive": {
+ "id": a.id,
+ "print_name": a.print_name or a.filename,
+ "status": a.status,
+ "created_at": a.created_at.isoformat() if a.created_at else None,
+ },
+ "match_reason": "Same print name",
+ "match_score": 100,
+ })
+
+ # By content hash
+ if reference.content_hash and len(similar) < limit:
+ result = await self.db.execute(
+ select(PrintArchive)
+ .where(
+ PrintArchive.id != archive_id,
+ PrintArchive.content_hash == reference.content_hash,
+ )
+ .order_by(PrintArchive.created_at.desc())
+ .limit(limit - len(similar))
+ )
+ for a in result.scalars().all():
+ if not any(s["archive"]["id"] == a.id for s in similar):
+ similar.append({
+ "archive": {
+ "id": a.id,
+ "print_name": a.print_name or a.filename,
+ "status": a.status,
+ "created_at": a.created_at.isoformat() if a.created_at else None,
+ },
+ "match_reason": "Same file content",
+ "match_score": 95,
+ })
+
+ # By same filament type
+ if reference.filament_type and len(similar) < limit:
+ result = await self.db.execute(
+ select(PrintArchive)
+ .where(
+ PrintArchive.id != archive_id,
+ PrintArchive.filament_type == reference.filament_type,
+ )
+ .order_by(PrintArchive.created_at.desc())
+ .limit(limit - len(similar))
+ )
+ for a in result.scalars().all():
+ if not any(s["archive"]["id"] == a.id for s in similar):
+ similar.append({
+ "archive": {
+ "id": a.id,
+ "print_name": a.print_name or a.filename,
+ "status": a.status,
+ "created_at": a.created_at.isoformat() if a.created_at else None,
+ },
+ "match_reason": f"Same filament type ({reference.filament_type})",
+ "match_score": 50,
+ })
+
+ # Sort by match score
+ similar.sort(key=lambda x: x["match_score"], reverse=True)
+
+ return similar[:limit]
diff --git a/backend/app/services/export.py b/backend/app/services/export.py
new file mode 100644
index 000000000..8b953d3df
--- /dev/null
+++ b/backend/app/services/export.py
@@ -0,0 +1,335 @@
+import csv
+import io
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.project import Project
+
+
+class ExportService:
+ """Service for exporting archive data to CSV/Excel formats."""
+
+ # Default fields to export
+ DEFAULT_FIELDS = [
+ "id",
+ "print_name",
+ "filename",
+ "status",
+ "printer_id",
+ "project_name",
+ "filament_type",
+ "filament_used_grams",
+ "print_time_seconds",
+ "layer_height",
+ "nozzle_diameter",
+ "bed_temperature",
+ "nozzle_temperature",
+ "total_layers",
+ "cost",
+ "designer",
+ "tags",
+ "notes",
+ "failure_reason",
+ "started_at",
+ "completed_at",
+ "created_at",
+ ]
+
+ # Field labels for headers
+ FIELD_LABELS = {
+ "id": "ID",
+ "print_name": "Print Name",
+ "filename": "Filename",
+ "status": "Status",
+ "printer_id": "Printer ID",
+ "project_name": "Project",
+ "filament_type": "Filament Type",
+ "filament_used_grams": "Filament (g)",
+ "print_time_seconds": "Print Time (s)",
+ "layer_height": "Layer Height (mm)",
+ "nozzle_diameter": "Nozzle (mm)",
+ "bed_temperature": "Bed Temp (°C)",
+ "nozzle_temperature": "Nozzle Temp (°C)",
+ "total_layers": "Total Layers",
+ "cost": "Cost",
+ "designer": "Designer",
+ "tags": "Tags",
+ "notes": "Notes",
+ "failure_reason": "Failure Reason",
+ "started_at": "Started At",
+ "completed_at": "Completed At",
+ "created_at": "Created At",
+ }
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def export_archives(
+ self,
+ format: str = "csv",
+ fields: list[str] | None = None,
+ printer_id: int | None = None,
+ project_id: int | None = None,
+ status: str | None = None,
+ date_from: datetime | None = None,
+ date_to: datetime | None = None,
+ search: str | None = None,
+ ) -> tuple[bytes, str, str]:
+ """Export archives to CSV or Excel format.
+
+ Args:
+ format: Export format ('csv' or 'xlsx')
+ fields: List of fields to include (None = all default fields)
+ printer_id: Filter by printer
+ project_id: Filter by project
+ status: Filter by status
+ date_from: Filter by start date
+ date_to: Filter by end date
+ search: Search filter
+
+ Returns:
+ Tuple of (file_bytes, filename, content_type)
+ """
+ # Build query
+ query = (
+ select(PrintArchive)
+ .options(selectinload(PrintArchive.project))
+ .order_by(PrintArchive.created_at.desc())
+ )
+
+ # Apply filters
+ if printer_id:
+ query = query.where(PrintArchive.printer_id == printer_id)
+ if project_id:
+ query = query.where(PrintArchive.project_id == project_id)
+ if status:
+ query = query.where(PrintArchive.status == status)
+ if date_from:
+ query = query.where(PrintArchive.created_at >= date_from)
+ if date_to:
+ query = query.where(PrintArchive.created_at <= date_to)
+ if search:
+ like_pattern = f"%{search}%"
+ query = query.where(
+ (PrintArchive.print_name.ilike(like_pattern)) |
+ (PrintArchive.filename.ilike(like_pattern)) |
+ (PrintArchive.tags.ilike(like_pattern)) |
+ (PrintArchive.notes.ilike(like_pattern)) |
+ (PrintArchive.designer.ilike(like_pattern))
+ )
+
+ # Execute query
+ result = await self.db.execute(query)
+ archives = list(result.scalars().all())
+
+ # Determine fields to export
+ export_fields = fields if fields else self.DEFAULT_FIELDS
+
+ # Convert to rows
+ rows = []
+ for archive in archives:
+ row = self._archive_to_row(archive, export_fields)
+ rows.append(row)
+
+ # Generate headers
+ headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]
+
+ # Generate file
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+ if format == "xlsx":
+ file_bytes = self._generate_xlsx(headers, rows, export_fields)
+ filename = f"archives_export_{timestamp}.xlsx"
+ content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ else:
+ file_bytes = self._generate_csv(headers, rows)
+ filename = f"archives_export_{timestamp}.csv"
+ content_type = "text/csv"
+
+ return file_bytes, filename, content_type
+
+ async def export_stats(
+ self,
+ format: str = "csv",
+ days: int = 30,
+ printer_id: int | None = None,
+ project_id: int | None = None,
+ ) -> tuple[bytes, str, str]:
+ """Export statistics summary to CSV or Excel format.
+
+ Args:
+ format: Export format ('csv' or 'xlsx')
+ days: Number of days to include in stats
+ printer_id: Filter by printer
+ project_id: Filter by project
+
+ Returns:
+ Tuple of (file_bytes, filename, content_type)
+ """
+ from backend.app.services.failure_analysis import FailureAnalysisService
+
+ # Get failure analysis data (includes stats)
+ analysis_service = FailureAnalysisService(self.db)
+ analysis = await analysis_service.analyze_failures(
+ days=days,
+ printer_id=printer_id,
+ project_id=project_id,
+ )
+
+ # Build stats rows
+ rows = [
+ ["Metric", "Value"],
+ ["Period (days)", analysis["period_days"]],
+ ["Total Prints", analysis["total_prints"]],
+ ["Failed Prints", analysis["failed_prints"]],
+ ["Failure Rate (%)", analysis["failure_rate"]],
+ [""],
+ ["Failures by Reason", ""],
+ ]
+
+ for reason, count in analysis["failures_by_reason"].items():
+ rows.append([reason, count])
+
+ rows.append([""])
+ rows.append(["Failures by Filament", ""])
+
+ for filament, count in analysis["failures_by_filament"].items():
+ rows.append([filament, count])
+
+ rows.append([""])
+ rows.append(["Failures by Printer", ""])
+
+ for printer, count in analysis["failures_by_printer"].items():
+ rows.append([printer, count])
+
+ rows.append([""])
+ rows.append(["Weekly Trend", ""])
+ rows.append(["Week", "Total", "Failed", "Rate (%)"])
+
+ for week in analysis["trend"]:
+ rows.append([
+ week["week_start"],
+ week["total_prints"],
+ week["failed_prints"],
+ week["failure_rate"],
+ ])
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+ if format == "xlsx":
+ file_bytes = self._generate_xlsx_simple(rows)
+ filename = f"stats_export_{timestamp}.xlsx"
+ content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ else:
+ file_bytes = self._generate_csv_simple(rows)
+ filename = f"stats_export_{timestamp}.csv"
+ content_type = "text/csv"
+
+ return file_bytes, filename, content_type
+
+ def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]:
+ """Convert an archive to a row of values."""
+ row = []
+ for field in fields:
+ if field == "project_name":
+ value = archive.project.name if archive.project else None
+ elif field in ("started_at", "completed_at", "created_at"):
+ value = getattr(archive, field)
+ if value:
+ value = value.isoformat()
+ else:
+ value = getattr(archive, field, None)
+ row.append(value)
+ return row
+
+ def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes:
+ """Generate CSV file content."""
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerow(headers)
+ writer.writerows(rows)
+ return output.getvalue().encode("utf-8")
+
+ def _generate_csv_simple(self, rows: list[list]) -> bytes:
+ """Generate CSV file content from simple rows (no separate headers)."""
+ output = io.StringIO()
+ writer = csv.writer(output)
+ writer.writerows(rows)
+ return output.getvalue().encode("utf-8")
+
+ def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes:
+ """Generate Excel file content."""
+ try:
+ from openpyxl import Workbook
+ from openpyxl.styles import Font, PatternFill, Alignment
+ from openpyxl.utils import get_column_letter
+ except ImportError:
+ raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Archives"
+
+ # Header style
+ header_font = Font(bold=True, color="FFFFFF")
+ header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
+ header_alignment = Alignment(horizontal="center")
+
+ # Write headers
+ for col, header in enumerate(headers, 1):
+ cell = ws.cell(row=1, column=col, value=header)
+ cell.font = header_font
+ cell.fill = header_fill
+ cell.alignment = header_alignment
+
+ # Write data
+ for row_idx, row in enumerate(rows, 2):
+ for col_idx, value in enumerate(row, 1):
+ ws.cell(row=row_idx, column=col_idx, value=value)
+
+ # Auto-adjust column widths
+ for col_idx, field in enumerate(fields, 1):
+ column_letter = get_column_letter(col_idx)
+ max_length = len(headers[col_idx - 1])
+ for row in rows:
+ cell_value = row[col_idx - 1]
+ if cell_value is not None:
+ max_length = max(max_length, len(str(cell_value)))
+ ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
+
+ # Freeze header row
+ ws.freeze_panes = "A2"
+
+ output = io.BytesIO()
+ wb.save(output)
+ return output.getvalue()
+
+ def _generate_xlsx_simple(self, rows: list[list]) -> bytes:
+ """Generate Excel file content from simple rows."""
+ try:
+ from openpyxl import Workbook
+ from openpyxl.styles import Font
+ except ImportError:
+ raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
+
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Statistics"
+
+ bold_font = Font(bold=True)
+
+ for row_idx, row in enumerate(rows, 1):
+ for col_idx, value in enumerate(row, 1):
+ cell = ws.cell(row=row_idx, column=col_idx, value=value)
+ # Bold section headers
+ if col_idx == 1 and value and isinstance(value, str) and value.endswith(":"):
+ cell.font = bold_font
+
+ output = io.BytesIO()
+ wb.save(output)
+ return output.getvalue()
diff --git a/backend/app/services/failure_analysis.py b/backend/app/services/failure_analysis.py
new file mode 100644
index 000000000..8c5eff9a3
--- /dev/null
+++ b/backend/app/services/failure_analysis.py
@@ -0,0 +1,198 @@
+from datetime import datetime, timedelta
+from collections import defaultdict
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.printer import Printer
+
+
+class FailureAnalysisService:
+ """Service for analyzing print failure patterns."""
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def analyze_failures(
+ self,
+ days: int = 30,
+ printer_id: int | None = None,
+ project_id: int | None = None,
+ ) -> dict:
+ """Analyze failure patterns across archives.
+
+ Args:
+ days: Number of days to analyze
+ printer_id: Optional filter by printer
+ project_id: Optional filter by project
+
+ Returns:
+ Dictionary with failure analysis results
+ """
+ cutoff_date = datetime.utcnow() - timedelta(days=days)
+
+ # Build base query
+ base_filter = [PrintArchive.created_at >= cutoff_date]
+ if printer_id:
+ base_filter.append(PrintArchive.printer_id == printer_id)
+ if project_id:
+ base_filter.append(PrintArchive.project_id == project_id)
+
+ # Total counts
+ total_result = await self.db.execute(
+ select(func.count(PrintArchive.id)).where(and_(*base_filter))
+ )
+ total_prints = total_result.scalar() or 0
+
+ failed_result = await self.db.execute(
+ select(func.count(PrintArchive.id)).where(
+ and_(*base_filter, PrintArchive.status == "failed")
+ )
+ )
+ failed_prints = failed_result.scalar() or 0
+
+ failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0
+
+ # Failures by reason
+ reason_result = await self.db.execute(
+ select(
+ PrintArchive.failure_reason,
+ func.count(PrintArchive.id).label("count"),
+ )
+ .where(and_(*base_filter, PrintArchive.status == "failed"))
+ .group_by(PrintArchive.failure_reason)
+ .order_by(func.count(PrintArchive.id).desc())
+ )
+ failures_by_reason = {
+ (row[0] or "Unknown"): row[1]
+ for row in reason_result.fetchall()
+ }
+
+ # Failures by filament type
+ filament_result = await self.db.execute(
+ select(
+ PrintArchive.filament_type,
+ func.count(PrintArchive.id).label("count"),
+ )
+ .where(and_(*base_filter, PrintArchive.status == "failed"))
+ .group_by(PrintArchive.filament_type)
+ .order_by(func.count(PrintArchive.id).desc())
+ )
+ failures_by_filament = {
+ (row[0] or "Unknown"): row[1]
+ for row in filament_result.fetchall()
+ }
+
+ # Failures by printer
+ printer_result = await self.db.execute(
+ select(
+ PrintArchive.printer_id,
+ func.count(PrintArchive.id).label("count"),
+ )
+ .where(
+ and_(*base_filter, PrintArchive.status == "failed", PrintArchive.printer_id.isnot(None))
+ )
+ .group_by(PrintArchive.printer_id)
+ .order_by(func.count(PrintArchive.id).desc())
+ )
+ failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
+
+ # Get printer names
+ if failures_by_printer_id:
+ printers_result = await self.db.execute(
+ select(Printer.id, Printer.name).where(
+ Printer.id.in_(failures_by_printer_id.keys())
+ )
+ )
+ printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
+ failures_by_printer = {
+ printer_names.get(pid, f"Printer {pid}"): count
+ for pid, count in failures_by_printer_id.items()
+ }
+ else:
+ failures_by_printer = {}
+
+ # Failures by hour of day
+ failed_archives_result = await self.db.execute(
+ select(PrintArchive.started_at)
+ .where(
+ and_(
+ *base_filter,
+ PrintArchive.status == "failed",
+ PrintArchive.started_at.isnot(None),
+ )
+ )
+ )
+ failures_by_hour = defaultdict(int)
+ for (started_at,) in failed_archives_result.fetchall():
+ if started_at:
+ hour = started_at.hour
+ failures_by_hour[hour] += 1
+ # Convert to dict with all 24 hours
+ failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
+
+ # Recent failures
+ recent_result = await self.db.execute(
+ select(PrintArchive)
+ .where(and_(*base_filter, PrintArchive.status == "failed"))
+ .order_by(PrintArchive.created_at.desc())
+ .limit(10)
+ )
+ recent_failures = [
+ {
+ "id": a.id,
+ "print_name": a.print_name or a.filename,
+ "failure_reason": a.failure_reason,
+ "filament_type": a.filament_type,
+ "printer_id": a.printer_id,
+ "created_at": a.created_at.isoformat() if a.created_at else None,
+ }
+ for a in recent_result.scalars().all()
+ ]
+
+ # Failure rate trend (by week)
+ trend_data = []
+ for i in range(min(days // 7, 12)): # Up to 12 weeks
+ week_end = datetime.utcnow() - timedelta(weeks=i)
+ week_start = week_end - timedelta(weeks=1)
+
+ week_filter = base_filter.copy()
+ week_filter[0] = and_(
+ PrintArchive.created_at >= week_start,
+ PrintArchive.created_at < week_end,
+ )
+
+ week_total = await self.db.execute(
+ select(func.count(PrintArchive.id)).where(and_(*week_filter))
+ )
+ week_failed = await self.db.execute(
+ select(func.count(PrintArchive.id)).where(
+ and_(*week_filter, PrintArchive.status == "failed")
+ )
+ )
+
+ total = week_total.scalar() or 0
+ failed = week_failed.scalar() or 0
+ rate = (failed / total * 100) if total > 0 else 0
+
+ trend_data.append({
+ "week_start": week_start.date().isoformat(),
+ "total_prints": total,
+ "failed_prints": failed,
+ "failure_rate": round(rate, 1),
+ })
+
+ trend_data.reverse() # Oldest first
+
+ return {
+ "period_days": days,
+ "total_prints": total_prints,
+ "failed_prints": failed_prints,
+ "failure_rate": round(failure_rate, 1),
+ "failures_by_reason": failures_by_reason,
+ "failures_by_filament": failures_by_filament,
+ "failures_by_printer": failures_by_printer,
+ "failures_by_hour": failures_by_hour_complete,
+ "recent_failures": recent_failures,
+ "trend": trend_data,
+ }
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
index 712db26dd..48eb8eef3 100644
--- a/frontend/public/manifest.json
+++ b/frontend/public/manifest.json
@@ -60,6 +60,13 @@
"description": "View print queue",
"url": "/queue",
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Projects",
+ "short_name": "Projects",
+ "description": "View print projects",
+ "url": "/projects",
+ "icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
}
]
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c93a4a64c..0042d2189 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { ProfilesPage } from './pages/ProfilesPage';
import { MaintenancePage } from './pages/MaintenancePage';
+import { ProjectsPage } from './pages/ProjectsPage';
import { CameraPage } from './pages/CameraPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { useWebSocket } from './hooks/useWebSocket';
@@ -46,6 +47,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 6ccd9d933..4c5b037c4 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -176,6 +176,8 @@ export interface ArchiveDuplicate {
export interface Archive {
id: number;
printer_id: number | null;
+ project_id: number | null;
+ project_name: string | null;
filename: string;
file_path: string;
file_size: number;
@@ -229,6 +231,31 @@ export interface ArchiveStats {
total_energy_cost: number;
}
+export interface FailureAnalysis {
+ period_days: number;
+ total_prints: number;
+ failed_prints: number;
+ failure_rate: number;
+ failures_by_reason: Record;
+ failures_by_filament: Record;
+ failures_by_printer: Record;
+ failures_by_hour: Record;
+ recent_failures: Array<{
+ id: number;
+ print_name: string;
+ failure_reason: string | null;
+ filament_type: string | null;
+ printer_id: number | null;
+ created_at: string | null;
+ }>;
+ trend: Array<{
+ week_start: string;
+ total_prints: number;
+ failed_prints: number;
+ failure_rate: number;
+ }>;
+}
+
export interface BulkUploadResult {
uploaded: number;
failed: number;
@@ -236,6 +263,159 @@ export interface BulkUploadResult {
errors: Array<{ filename: string; error: string }>;
}
+// Archive Comparison types
+export interface ComparisonArchiveInfo {
+ id: number;
+ print_name: string;
+ status: string;
+ created_at: string | null;
+ printer_id: number | null;
+ project_name: string | null;
+}
+
+export interface ComparisonField {
+ field: string;
+ label: string;
+ unit: string | null;
+ values: (string | number | null)[];
+ raw_values: (string | number | null)[];
+ has_difference: boolean;
+}
+
+export interface SuccessCorrelationInsight {
+ field: string;
+ label: string;
+ insight: string;
+ success_avg?: number;
+ failed_avg?: number;
+ success_values?: string[];
+ failed_values?: string[];
+}
+
+export interface SuccessCorrelation {
+ has_both_outcomes: boolean;
+ message?: string;
+ successful_count?: number;
+ failed_count?: number;
+ insights?: SuccessCorrelationInsight[];
+}
+
+export interface ArchiveComparison {
+ archives: ComparisonArchiveInfo[];
+ comparison: ComparisonField[];
+ differences: ComparisonField[];
+ success_correlation: SuccessCorrelation;
+}
+
+export interface SimilarArchive {
+ archive: {
+ id: number;
+ print_name: string;
+ status: string;
+ created_at: string | null;
+ };
+ match_reason: string;
+ match_score: number;
+}
+
+// Project types
+export interface ProjectStats {
+ total_archives: number;
+ completed_prints: number;
+ failed_prints: number;
+ queued_prints: number;
+ in_progress_prints: number;
+ total_print_time_hours: number;
+ total_filament_grams: number;
+ progress_percent: number | null;
+}
+
+export interface Project {
+ id: number;
+ name: string;
+ description: string | null;
+ color: string | null;
+ status: string; // active, completed, archived
+ target_count: number | null;
+ created_at: string;
+ updated_at: string;
+ stats?: ProjectStats;
+}
+
+export interface ArchivePreview {
+ id: number;
+ print_name: string | null;
+ thumbnail_path: string | null;
+ status: string;
+}
+
+export interface ProjectListItem {
+ id: number;
+ name: string;
+ description: string | null;
+ color: string | null;
+ status: string;
+ target_count: number | null;
+ created_at: string;
+ archive_count: number;
+ queue_count: number;
+ progress_percent: number | null;
+ archives: ArchivePreview[];
+}
+
+export interface ProjectCreate {
+ name: string;
+ description?: string;
+ color?: string;
+ target_count?: number;
+}
+
+export interface ProjectUpdate {
+ name?: string;
+ description?: string;
+ color?: string;
+ status?: string;
+ target_count?: number;
+}
+
+// API Key types
+export interface APIKey {
+ id: number;
+ name: string;
+ key_prefix: string;
+ can_queue: boolean;
+ can_control_printer: boolean;
+ can_read_status: boolean;
+ printer_ids: number[] | null;
+ enabled: boolean;
+ last_used: string | null;
+ created_at: string;
+ expires_at: string | null;
+}
+
+export interface APIKeyCreate {
+ name: string;
+ can_queue?: boolean;
+ can_control_printer?: boolean;
+ can_read_status?: boolean;
+ printer_ids?: number[] | null;
+ expires_at?: string | null;
+}
+
+export interface APIKeyCreateResponse extends APIKey {
+ key: string; // Full key, only shown on creation
+}
+
+export interface APIKeyUpdate {
+ name?: string;
+ can_queue?: boolean;
+ can_control_printer?: boolean;
+ can_read_status?: boolean;
+ printer_ids?: number[] | null;
+ enabled?: boolean;
+ expires_at?: string | null;
+}
+
// Settings types
export interface AppSettings {
auto_archive: boolean;
@@ -970,16 +1150,35 @@ export const api = {
request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
// Archives
- getArchives: (printerId?: number, limit = 50, offset = 0) => {
+ getArchives: (printerId?: number, projectId?: number, limit = 50, offset = 0) => {
const params = new URLSearchParams();
if (printerId) params.set('printer_id', String(printerId));
+ if (projectId) params.set('project_id', String(projectId));
params.set('limit', String(limit));
params.set('offset', String(offset));
return request(`/archives/?${params}`);
},
getArchive: (id: number) => request(`/archives/${id}`),
+ searchArchives: (query: string, options?: {
+ printerId?: number;
+ projectId?: number;
+ status?: string;
+ limit?: number;
+ offset?: number;
+ }) => {
+ const params = new URLSearchParams();
+ params.set('q', query);
+ if (options?.printerId) params.set('printer_id', String(options.printerId));
+ if (options?.projectId) params.set('project_id', String(options.projectId));
+ if (options?.status) params.set('status', options.status);
+ if (options?.limit) params.set('limit', String(options.limit));
+ if (options?.offset) params.set('offset', String(options.offset));
+ return request(`/archives/search?${params}`);
+ },
+ rebuildSearchIndex: () => request<{ message: string }>('/archives/search/rebuild-index', { method: 'POST' }),
updateArchive: (id: number, data: {
printer_id?: number | null;
+ project_id?: number | null;
print_name?: string;
is_favorite?: boolean;
tags?: string;
@@ -996,6 +1195,81 @@ export const api = {
deleteArchive: (id: number) =>
request(`/archives/${id}`, { method: 'DELETE' }),
getArchiveStats: () => request('/archives/stats'),
+ getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
+ const params = new URLSearchParams();
+ if (options?.days) params.set('days', String(options.days));
+ if (options?.printerId) params.set('printer_id', String(options.printerId));
+ if (options?.projectId) params.set('project_id', String(options.projectId));
+ return request(`/archives/analysis/failures?${params}`);
+ },
+ compareArchives: (archiveIds: number[]) =>
+ request(`/archives/compare?archive_ids=${archiveIds.join(',')}`),
+ findSimilarArchives: (archiveId: number, limit = 10) =>
+ request(`/archives/${archiveId}/similar?limit=${limit}`),
+ exportArchives: async (options?: {
+ format?: 'csv' | 'xlsx';
+ fields?: string[];
+ printerId?: number;
+ projectId?: number;
+ status?: string;
+ dateFrom?: string;
+ dateTo?: string;
+ search?: string;
+ }): Promise<{ blob: Blob; filename: string }> => {
+ const params = new URLSearchParams();
+ if (options?.format) params.set('format', options.format);
+ if (options?.fields) params.set('fields', options.fields.join(','));
+ if (options?.printerId) params.set('printer_id', String(options.printerId));
+ if (options?.projectId) params.set('project_id', String(options.projectId));
+ if (options?.status) params.set('status', options.status);
+ if (options?.dateFrom) params.set('date_from', options.dateFrom);
+ if (options?.dateTo) params.set('date_to', options.dateTo);
+ if (options?.search) params.set('search', options.search);
+
+ const response = await fetch(`${API_BASE}/archives/export?${params}`);
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ const contentDisposition = response.headers.get('Content-Disposition');
+ let filename = options?.format === 'xlsx' ? 'archives_export.xlsx' : 'archives_export.csv';
+ if (contentDisposition) {
+ const match = contentDisposition.match(/filename="?([^"]+)"?/);
+ if (match) filename = match[1];
+ }
+
+ const blob = await response.blob();
+ return { blob, filename };
+ },
+ exportStats: async (options?: {
+ format?: 'csv' | 'xlsx';
+ days?: number;
+ printerId?: number;
+ projectId?: number;
+ }): Promise<{ blob: Blob; filename: string }> => {
+ const params = new URLSearchParams();
+ if (options?.format) params.set('format', options.format);
+ if (options?.days) params.set('days', String(options.days));
+ if (options?.printerId) params.set('printer_id', String(options.printerId));
+ if (options?.projectId) params.set('project_id', String(options.projectId));
+
+ const response = await fetch(`${API_BASE}/archives/stats/export?${params}`);
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({}));
+ throw new Error(error.detail || `HTTP ${response.status}`);
+ }
+
+ const contentDisposition = response.headers.get('Content-Disposition');
+ let filename = options?.format === 'xlsx' ? 'stats_export.xlsx' : 'stats_export.csv';
+ if (contentDisposition) {
+ const match = contentDisposition.match(/filename="?([^"]+)"?/);
+ if (match) filename = match[1];
+ }
+
+ const blob = await response.blob();
+ return { blob, filename };
+ },
getArchiveDuplicates: (id: number) =>
request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
backfillContentHashes: () =>
@@ -1567,4 +1841,56 @@ export const api = {
deleteExternalLinkIcon: (id: number) =>
request(`/external-links/${id}/icon`, { method: 'DELETE' }),
getExternalLinkIconUrl: (id: number) => `${API_BASE}/external-links/${id}/icon`,
+
+ // Projects
+ getProjects: (status?: string) => {
+ const params = new URLSearchParams();
+ if (status) params.set('status', status);
+ return request(`/projects/?${params}`);
+ },
+ getProject: (id: number) => request(`/projects/${id}`),
+ createProject: (data: ProjectCreate) =>
+ request('/projects/', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ updateProject: (id: number, data: ProjectUpdate) =>
+ request(`/projects/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ }),
+ deleteProject: (id: number) =>
+ request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }),
+ getProjectArchives: (id: number, limit = 100, offset = 0) =>
+ request(`/projects/${id}/archives?limit=${limit}&offset=${offset}`),
+ addArchivesToProject: (projectId: number, archiveIds: number[]) =>
+ request<{ message: string }>(`/projects/${projectId}/add-archives`, {
+ method: 'POST',
+ body: JSON.stringify({ archive_ids: archiveIds }),
+ }),
+ removeArchivesFromProject: (projectId: number, archiveIds: number[]) =>
+ request<{ message: string }>(`/projects/${projectId}/remove-archives`, {
+ method: 'POST',
+ body: JSON.stringify({ archive_ids: archiveIds }),
+ }),
+ addQueueItemsToProject: (projectId: number, queueItemIds: number[]) =>
+ request<{ message: string }>(`/projects/${projectId}/add-queue`, {
+ method: 'POST',
+ body: JSON.stringify({ queue_item_ids: queueItemIds }),
+ }),
+
+ // API Keys
+ getAPIKeys: () => request('/api-keys/'),
+ createAPIKey: (data: APIKeyCreate) =>
+ request('/api-keys/', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ }),
+ updateAPIKey: (id: number, data: APIKeyUpdate) =>
+ request(`/api-keys/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(data),
+ }),
+ deleteAPIKey: (id: number) =>
+ request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }),
};
diff --git a/frontend/src/components/CompareArchivesModal.tsx b/frontend/src/components/CompareArchivesModal.tsx
new file mode 100644
index 000000000..8ad587e57
--- /dev/null
+++ b/frontend/src/components/CompareArchivesModal.tsx
@@ -0,0 +1,190 @@
+import { useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { X, Check, AlertTriangle, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { ArchiveComparison } from '../api/client';
+import { Button } from './Button';
+
+interface CompareArchivesModalProps {
+ archiveIds: number[];
+ onClose: () => void;
+}
+
+export function CompareArchivesModal({ archiveIds, onClose }: CompareArchivesModalProps) {
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const { data: comparison, isLoading, error } = useQuery({
+ queryKey: ['archive-comparison', archiveIds],
+ queryFn: () => api.compareArchives(archiveIds),
+ });
+
+ return (
+
+ e.stopPropagation()}>
+ {/* Header */}
+
+
+ Compare Archives ({archiveIds.length})
+
+
+
+
+ {/* Content */}
+
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+
+ Failed to load comparison
+
+ {error instanceof Error ? error.message : 'Unknown error'}
+
+
+ ) : comparison ? (
+
+ ) : null}
+
+
+ {/* Footer */}
+
+
+
+
+
+ );
+}
+
+function ComparisonContent({ comparison }: { comparison: ArchiveComparison }) {
+ return (
+
+ {/* Archive Headers */}
+
+
+
+
+ |
+ Setting
+ |
+ {comparison.archives.map((archive) => (
+
+
+ {archive.print_name}
+
+
+ {archive.status}
+
+ |
+ ))}
+
+
+
+ {comparison.comparison.map((field) => (
+
+
+
+ {field.has_difference && (
+
+ )}
+
+ {field.label}
+
+
+ |
+ {field.values.map((value, idx) => (
+
+ {value ?? -}
+ {field.unit && value !== null && (
+ {field.unit}
+ )}
+ |
+ ))}
+
+ ))}
+
+
+
+
+ {/* Differences Summary */}
+ {comparison.differences.length > 0 && (
+
+
+
+ {comparison.differences.length} Difference{comparison.differences.length > 1 ? 's' : ''} Found
+
+
+ {comparison.differences.slice(0, 5).map((diff) => (
+ -
+ {diff.label}: {diff.values.join(' vs ')} {diff.unit || ''}
+
+ ))}
+ {comparison.differences.length > 5 && (
+ -
+ ...and {comparison.differences.length - 5} more
+
+ )}
+
+
+ )}
+
+ {/* Success Correlation */}
+ {comparison.success_correlation.has_both_outcomes ? (
+
+
+
+ Success/Failure Analysis
+
+
+
+ {comparison.success_correlation.successful_count} successful
+
+
+ {comparison.success_correlation.failed_count} failed
+
+
+ {comparison.success_correlation.insights && comparison.success_correlation.insights.length > 0 ? (
+
+ {comparison.success_correlation.insights.map((insight) => (
+
+ {insight.label}:{' '}
+ {insight.insight}
+
+ ))}
+
+ ) : (
+ No clear correlations found between settings and outcomes.
+ )}
+
+ ) : (
+
+ {comparison.success_correlation.message || 'Need both successful and failed prints for correlation analysis.'}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx
index 943831d1f..c1198657e 100644
--- a/frontend/src/components/ContextMenu.tsx
+++ b/frontend/src/components/ContextMenu.tsx
@@ -1,4 +1,5 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { ChevronRight } from 'lucide-react';
export interface ContextMenuItem {
label: string;
@@ -7,6 +8,7 @@ export interface ContextMenuItem {
danger?: boolean;
disabled?: boolean;
divider?: boolean;
+ submenu?: ContextMenuItem[];
}
interface ContextMenuProps {
@@ -18,6 +20,8 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef(null);
+ const [activeSubmenu, setActiveSubmenu] = useState(null);
+ const submenuTimeoutRef = useRef(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -44,6 +48,9 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('scroll', handleScroll, true);
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ }
};
}, [onClose]);
@@ -69,10 +76,24 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
}
}, [x, y]);
+ const handleMouseEnterSubmenu = (index: number) => {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ submenuTimeoutRef.current = null;
+ }
+ setActiveSubmenu(index);
+ };
+
+ const handleMouseLeaveSubmenu = () => {
+ submenuTimeoutRef.current = window.setTimeout(() => {
+ setActiveSubmenu(null);
+ }, 150);
+ };
+
return (
{items.map((item, index) => {
@@ -80,27 +101,75 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
return ;
}
+ const hasSubmenu = item.submenu && item.submenu.length > 0;
+
return (
-
+
+ {/* Submenu */}
+ {hasSubmenu && activeSubmenu === index && (
+ {
+ if (submenuTimeoutRef.current) {
+ clearTimeout(submenuTimeoutRef.current);
+ submenuTimeoutRef.current = null;
+ }
+ }}
+ onMouseLeave={() => handleMouseLeaveSubmenu()}
+ >
+ {item.submenu!.map((subItem, subIndex) => (
+
+ ))}
+
+ )}
+
);
})}
diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx
index 28661cedd..e6ba17dc4 100644
--- a/frontend/src/components/Dashboard.tsx
+++ b/frontend/src/components/Dashboard.tsx
@@ -31,6 +31,8 @@ interface DashboardProps {
widgets: DashboardWidget[];
storageKey: string;
columns?: number;
+ hideControls?: boolean;
+ onResetLayout?: () => void;
}
interface LayoutState {
@@ -124,7 +126,7 @@ function SortableWidget({
);
}
-export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps) {
+export function Dashboard({ widgets, storageKey, columns = 4, hideControls = false, onResetLayout }: DashboardProps) {
// Build default sizes from widget definitions
const getDefaultSizes = () => {
const sizes: Record = {};
@@ -233,6 +235,7 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps)
sizes: getDefaultSizes(),
};
setLayout(defaultLayout);
+ onResetLayout?.();
};
// Get ordered widgets
@@ -246,22 +249,24 @@ export function Dashboard({ widgets, storageKey, columns = 4 }: DashboardProps)
return (
{/* Dashboard Controls */}
-
- {hiddenWidgets.length > 0 && (
-
+ )}
{/* Hidden Widgets Panel */}
{showHiddenPanel && hiddenWidgets.length > 0 && (
diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx
index 77205adb5..fa36917c3 100644
--- a/frontend/src/components/EditArchiveModal.tsx
+++ b/frontend/src/components/EditArchiveModal.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Tag, Camera, Trash2, Loader2, Plus } from 'lucide-react';
+import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban } from 'lucide-react';
import { api } from '../api/client';
import type { Archive } from '../api/client';
import { Button } from './Button';
@@ -37,6 +37,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
const queryClient = useQueryClient();
const [printName, setPrintName] = useState(archive.print_name || '');
const [printerId, setPrinterId] = useState (archive.printer_id);
+ const [projectId, setProjectId] = useState(archive.project_id ?? null);
const [notes, setNotes] = useState(archive.notes || '');
const [tags, setTags] = useState(archive.tags || '');
const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
@@ -52,6 +53,11 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
queryFn: api.getPrinters,
});
+ const { data: projects } = useQuery({
+ queryKey: ['projects'],
+ queryFn: () => api.getProjects(),
+ });
+
// Get all archives to extract existing tags if not provided
const { data: archives } = useQuery({
queryKey: ['archives'],
@@ -96,6 +102,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
api.updateArchive(archive.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
+ queryClient.invalidateQueries({ queryKey: ['projects'] });
onClose();
},
});
@@ -134,6 +141,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
updateMutation.mutate({
print_name: printName || undefined,
printer_id: printerId,
+ project_id: projectId,
notes: notes || undefined,
tags: tags || undefined,
failure_reason: archive.status === 'failed' ? (failureReason || undefined) : undefined,
@@ -191,6 +199,26 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
+ {/* Project */}
+
+
+
+
+
{/* Notes */}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 22e9afae2..8f207276f 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, X, Menu, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -23,6 +23,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
{ id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
{ id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
+ { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
];
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index 686c4be56..45e031401 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -7,6 +7,7 @@ export default {
stats: 'Statistiken',
profiles: 'Profile',
maintenance: 'Wartung',
+ projects: 'Projekte',
settings: 'Einstellungen',
collapseSidebar: 'Seitenleiste einklappen',
expandSidebar: 'Seitenleiste ausklappen',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index a853b8012..7b2f0347e 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -7,6 +7,7 @@ export default {
stats: 'Statistics',
profiles: 'Profiles',
maintenance: 'Maintenance',
+ projects: 'Projects',
settings: 'Settings',
collapseSidebar: 'Collapse sidebar',
expandSidebar: 'Expand sidebar',
diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx
index 2688d28e3..00f7118e3 100644
--- a/frontend/src/pages/ArchivesPage.tsx
+++ b/frontend/src/pages/ArchivesPage.tsx
@@ -36,10 +36,14 @@ import {
FileText,
FileCode,
MoreVertical,
+ FileSpreadsheet,
+ GitCompare,
+ Loader2,
+ FolderKanban,
} from 'lucide-react';
import { api } from '../api/client';
import { useIsMobile } from '../hooks/useIsMobile';
-import type { Archive } from '../api/client';
+import type { Archive, ProjectListItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ModelViewerModal } from '../components/ModelViewerModal';
@@ -55,6 +59,7 @@ import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
import { ProjectPageModal } from '../components/ProjectPageModal';
import { TimelapseViewer } from '../components/TimelapseViewer';
import { AddToQueueModal } from '../components/AddToQueueModal';
+import { CompareArchivesModal } from '../components/CompareArchivesModal';
import { useToast } from '../contexts/ToastContext';
function formatFileSize(bytes: number): string {
@@ -86,12 +91,14 @@ function ArchiveCard({
isSelected,
onSelect,
selectionMode,
+ projects,
}: {
archive: Archive;
printerName: string;
isSelected: boolean;
onSelect: (id: number) => void;
selectionMode: boolean;
+ projects: ProjectListItem[] | undefined;
}) {
const queryClient = useQueryClient();
const { showToast } = useToast();
@@ -186,6 +193,18 @@ function ArchiveCard({
},
});
+ const assignProjectMutation = useMutation({
+ mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['archives'] });
+ queryClient.invalidateQueries({ queryKey: ['projects'] });
+ showToast('Project updated');
+ },
+ onError: () => {
+ showToast('Failed to update project', 'error');
+ },
+ });
+
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
@@ -311,6 +330,59 @@ function ArchiveCard({
icon: ,
onClick: () => setShowEdit(true),
},
+ ...(archive.project_id && archive.project_name ? [{
+ label: `Go to Project: ${archive.project_name}`,
+ icon: ,
+ onClick: () => window.location.href = '/projects',
+ }] : []),
+ {
+ label: 'Add to Project',
+ icon: ,
+ onClick: () => {},
+ submenu: (() => {
+ const items: ContextMenuItem[] = [];
+
+ // Add "Remove from Project" if archive is in a project
+ if (archive.project_id) {
+ items.push({
+ label: 'Remove from Project',
+ icon: ,
+ onClick: () => assignProjectMutation.mutate(null),
+ });
+ }
+
+ // Add project options
+ if (!projects) {
+ items.push({
+ label: 'Loading...',
+ icon: ,
+ onClick: () => {},
+ disabled: true,
+ });
+ } else {
+ const activeProjects = projects.filter(p => p.status === 'active');
+ if (activeProjects.length === 0) {
+ items.push({
+ label: 'No projects available',
+ icon: ,
+ onClick: () => {},
+ disabled: true,
+ });
+ } else {
+ activeProjects.forEach(p => {
+ items.push({
+ label: p.name,
+ icon: ,
+ onClick: () => assignProjectMutation.mutate(p.id),
+ disabled: archive.project_id === p.id,
+ });
+ });
+ }
+ }
+
+ return items;
+ })(),
+ },
{
label: isSelected ? 'Deselect' : 'Select',
icon: isSelected ? : ,
@@ -454,7 +526,21 @@ function ArchiveCard({
{archive.print_name || archive.filename}
- {printerName}
+
+ {printerName}
+ {archive.project_name && (
+ p.id === archive.project_id)?.color || '#6b7280'}20`,
+ color: projects?.find(p => p.id === archive.project_id)?.color || '#6b7280'
+ }}
+ title={`Project: ${archive.project_name}`}
+ >
+ {archive.project_name}
+
+ )}
+
{/* Stats */}
@@ -857,6 +943,9 @@ export function ArchivesPage() {
const [viewMode, setViewMode] = useState('grid');
const [sortBy, setSortBy] = useState('date-desc');
const [collection, setCollection] = useState('all');
+ const [showExportMenu, setShowExportMenu] = useState(false);
+ const [isExporting, setIsExporting] = useState(false);
+ const [showCompareModal, setShowCompareModal] = useState(false);
const { data: archives, isLoading } = useQuery({
queryKey: ['archives', filterPrinter],
@@ -868,6 +957,11 @@ export function ArchivesPage() {
queryFn: api.getPrinters,
});
+ const { data: projects } = useQuery({
+ queryKey: ['projects'],
+ queryFn: () => api.getProjects(),
+ });
+
const bulkDeleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
await Promise.all(ids.map((id) => api.deleteArchive(id)));
@@ -1176,6 +1270,93 @@ export function ArchivesPage() {
+ {/* Export dropdown */}
+
+ setShowExportMenu(!showExportMenu)}
+ disabled={isExporting}
+ >
+ {isExporting ? (
+
+ ) : (
+
+ )}
+ Export
+
+ {showExportMenu && (
+
+ {
+ setShowExportMenu(false);
+ setIsExporting(true);
+ try {
+ const { blob, filename } = await api.exportArchives({
+ format: 'csv',
+ printerId: filterPrinter || undefined,
+ status: collection === 'failed' ? 'failed' : undefined,
+ search: search || undefined,
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ showToast('Export downloaded');
+ } catch (err) {
+ showToast('Export failed', 'error');
+ } finally {
+ setIsExporting(false);
+ }
+ }}
+ >
+
+ Export as CSV
+
+ {
+ setShowExportMenu(false);
+ setIsExporting(true);
+ try {
+ const { blob, filename } = await api.exportArchives({
+ format: 'xlsx',
+ printerId: filterPrinter || undefined,
+ status: collection === 'failed' ? 'failed' : undefined,
+ search: search || undefined,
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ showToast('Export downloaded');
+ } catch (err) {
+ showToast('Export failed', 'error');
+ } finally {
+ setIsExporting(false);
+ }
+ }}
+ >
+
+ Export as Excel
+
+
+ )}
+
+ {/* Compare button (only when 2-5 items selected) */}
+ {selectedIds.size >= 2 && selectedIds.size <= 5 && (
+ setShowCompareModal(true)}
+ >
+
+ Compare ({selectedIds.size})
+
+ )}
{!selectionMode && (
setIsSelectionMode(true)}>
@@ -1402,6 +1583,7 @@ export function ArchivesPage() {
isSelected={selectedIds.has(archive.id)}
onSelect={toggleSelect}
selectionMode={selectionMode}
+ projects={projects}
/>
))}
@@ -1560,6 +1742,18 @@ export function ArchivesPage() {
onClose={() => setShowBatchTag(false)}
/>
)}
+
+ {/* Compare Archives Modal */}
+ {showCompareModal && selectedIds.size >= 2 && selectedIds.size <= 5 && (
+ {
+ setShowCompareModal(false);
+ setSelectedIds(new Set());
+ setIsSelectionMode(false);
+ }}
+ />
+ )}
);
}
diff --git a/frontend/src/pages/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage.tsx
new file mode 100644
index 000000000..9d7161709
--- /dev/null
+++ b/frontend/src/pages/ProjectsPage.tsx
@@ -0,0 +1,453 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ FolderKanban,
+ Loader2,
+ Plus,
+ Trash2,
+ Edit3,
+ Archive,
+ ListTodo,
+ Package,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
+import { Card, CardContent } from '../components/Card';
+import { Button } from '../components/Button';
+import { ConfirmModal } from '../components/ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+
+const PROJECT_COLORS = [
+ '#ef4444', // red
+ '#f97316', // orange
+ '#eab308', // yellow
+ '#22c55e', // green
+ '#06b6d4', // cyan
+ '#3b82f6', // blue
+ '#8b5cf6', // violet
+ '#ec4899', // pink
+ '#6b7280', // gray
+];
+
+interface ProjectModalProps {
+ project?: ProjectListItem;
+ onClose: () => void;
+ onSave: (data: ProjectCreate | ProjectUpdate) => void;
+ isLoading: boolean;
+}
+
+function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
+ const [name, setName] = useState(project?.name || '');
+ const [description, setDescription] = useState(project?.description || '');
+ const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
+ const [targetCount, setTargetCount] = useState(project?.target_count?.toString() || '');
+ const [status, setStatus] = useState(project?.status || 'active');
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSave({
+ name: name.trim(),
+ description: description.trim() || undefined,
+ color,
+ target_count: targetCount ? parseInt(targetCount, 10) : undefined,
+ ...(project && { status }),
+ });
+ };
+
+ return (
+
+
+
+
+ {project ? 'Edit Project' : 'New Project'}
+
+
+
+
+
+
+ );
+}
+
+interface ProjectCardProps {
+ project: ProjectListItem;
+ onClick: () => void;
+ onEdit: () => void;
+ onDelete: () => void;
+}
+
+function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
+ const progressPercent = project.progress_percent ?? 0;
+ const isCompleted = project.status === 'completed';
+ const isArchived = project.status === 'archived';
+
+ return (
+
+
+
+
+
+
+ {project.name}
+ {project.description && (
+
+ {project.description}
+
+ )}
+
+
+ e.stopPropagation()}>
+ {isCompleted && (
+
+ Completed
+
+ )}
+ {isArchived && (
+
+ Archived
+
+ )}
+
+
+
+
+
+
+
+
+
+ {/* Progress bar */}
+ {project.target_count && (
+
+
+ {project.archive_count} / {project.target_count} prints
+ {progressPercent.toFixed(0)}%
+
+
+ = 100 ? '#22c55e' : project.color || '#6b7280',
+ }}
+ />
+
+
+ )}
+
+ {/* Archive thumbnails */}
+ {project.archives && project.archives.length > 0 && (
+
+ )}
+
+ {/* Stats */}
+
+
+
+ {project.archive_count}
+
+
+
+ {project.queue_count}
+
+
+
+
+ );
+}
+
+export function ProjectsPage() {
+ const queryClient = useQueryClient();
+ const { showToast } = useToast();
+ const [showModal, setShowModal] = useState(false);
+ const [editingProject, setEditingProject] = useState ();
+ const [statusFilter, setStatusFilter] = useState('active');
+ const [deleteConfirm, setDeleteConfirm] = useState(null);
+
+ const { data: projects, isLoading } = useQuery({
+ queryKey: ['projects', statusFilter === 'all' ? undefined : statusFilter],
+ queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
+ });
+
+ const createMutation = useMutation({
+ mutationFn: (data: ProjectCreate) => api.createProject(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['projects'] });
+ setShowModal(false);
+ showToast('Project created', 'success');
+ },
+ onError: (error: Error) => {
+ showToast(error.message, 'error');
+ },
+ });
+
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: number; data: ProjectUpdate }) =>
+ api.updateProject(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['projects'] });
+ setShowModal(false);
+ setEditingProject(undefined);
+ showToast('Project updated', 'success');
+ },
+ onError: (error: Error) => {
+ showToast(error.message, 'error');
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: (id: number) => api.deleteProject(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['projects'] });
+ setDeleteConfirm(null);
+ showToast('Project deleted', 'success');
+ },
+ onError: (error: Error) => {
+ showToast(error.message, 'error');
+ },
+ });
+
+ const handleSave = (data: ProjectCreate | ProjectUpdate) => {
+ if (editingProject) {
+ updateMutation.mutate({ id: editingProject.id, data });
+ } else {
+ createMutation.mutate(data as ProjectCreate);
+ }
+ };
+
+ const handleEdit = (project: ProjectListItem) => {
+ setEditingProject(project);
+ setShowModal(true);
+ };
+
+ const handleClick = (project: ProjectListItem) => {
+ // Open edit modal when clicking on card
+ handleEdit(project);
+ };
+
+ const handleDeleteClick = (id: number) => {
+ setDeleteConfirm(id);
+ };
+
+ const handleDeleteConfirm = () => {
+ if (deleteConfirm !== null) {
+ deleteMutation.mutate(deleteConfirm);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+ Projects
+
+ setShowModal(true)}>
+
+ New Project
+
+
+
+ {/* Filters */}
+
+ {['active', 'completed', 'archived', 'all'].map((status) => (
+ setStatusFilter(status)}
+ className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
+ statusFilter === status
+ ? 'bg-bambu-green text-white'
+ : 'bg-bambu-card text-bambu-gray hover:bg-bambu-gray/20'
+ }`}
+ >
+ {status.charAt(0).toUpperCase() + status.slice(1)}
+
+ ))}
+
+
+ {/* Content */}
+ {isLoading ? (
+
+
+
+ ) : projects?.length === 0 ? (
+
+
+
+ No projects found
+
+ Create a project to group related prints together
+
+
+
+ ) : (
+
+ {projects?.map((project) => (
+ handleClick(project)}
+ onEdit={() => handleEdit(project)}
+ onDelete={() => handleDeleteClick(project.id)}
+ />
+ ))}
+
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {deleteConfirm !== null && (
+ setDeleteConfirm(null)}
+ />
+ )}
+
+ {/* Modal */}
+ {showModal && (
+ {
+ setShowModal(false);
+ setEditingProject(undefined);
+ }}
+ onSave={handleSave}
+ isLoading={createMutation.isPending || updateMutation.isPending}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 17b5b2f47..8054f2549 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { AppSettings, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
@@ -33,7 +33,16 @@ export function SettingsPage() {
const [editingTemplate, setEditingTemplate] = useState(null);
const [showLogViewer, setShowLogViewer] = useState(false);
const [defaultView, setDefaultViewState] = useState(getDefaultView());
- const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications'>('general');
+ const [activeTab, setActiveTab] = useState<'general' | 'plugs' | 'notifications' | 'apikeys'>('general');
+ const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
+ const [newAPIKeyName, setNewAPIKeyName] = useState('');
+ const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
+ can_queue: true,
+ can_control_printer: false,
+ can_read_status: true,
+ });
+ const [createdAPIKey, setCreatedAPIKey] = useState(null);
+ const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState(null);
// Confirm modal states
const [showClearLogsConfirm, setShowClearLogsConfirm] = useState(false);
@@ -113,6 +122,38 @@ export function SettingsPage() {
queryFn: api.getNotificationProviders,
});
+ const { data: apiKeys, isLoading: apiKeysLoading } = useQuery({
+ queryKey: ['api-keys'],
+ queryFn: api.getAPIKeys,
+ enabled: activeTab === 'apikeys',
+ });
+
+ const createAPIKeyMutation = useMutation({
+ mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean }) =>
+ api.createAPIKey(data),
+ onSuccess: (data) => {
+ setCreatedAPIKey(data.key || null);
+ setShowCreateAPIKey(false);
+ setNewAPIKeyName('');
+ queryClient.invalidateQueries({ queryKey: ['api-keys'] });
+ showToast('API key created');
+ },
+ onError: (error: Error) => {
+ showToast(`Failed to create API key: ${error.message}`, 'error');
+ },
+ });
+
+ const deleteAPIKeyMutation = useMutation({
+ mutationFn: (id: number) => api.deleteAPIKey(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['api-keys'] });
+ showToast('API key deleted');
+ },
+ onError: (error: Error) => {
+ showToast(`Failed to delete API key: ${error.message}`, 'error');
+ },
+ });
+
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
@@ -362,6 +403,22 @@ export function SettingsPage() {
)}
+ setActiveTab('apikeys')}
+ className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+ activeTab === 'apikeys'
+ ? 'text-bambu-green border-bambu-green'
+ : 'text-bambu-gray hover:text-white border-transparent'
+ }`}
+ >
+
+ API Keys
+ {apiKeys && apiKeys.length > 0 && (
+
+ {apiKeys.length}
+
+ )}
+
{/* General Tab */}
@@ -1343,6 +1400,285 @@ export function SettingsPage() {
)}
+ {/* API Keys Tab */}
+ {activeTab === 'apikeys' && (
+
+
+
+
+
+ API Keys
+
+
+ Create API keys for external integrations and webhooks. Use these keys to control your printers from automation tools like Home Assistant.
+
+
+ setShowCreateAPIKey(true)} className="flex-shrink-0">
+
+ Create Key
+
+
+
+ {/* Created Key Display */}
+ {createdAPIKey && (
+
+
+
+
+
+ API Key Created Successfully
+
+ Copy this key now - it won't be shown again!
+
+
+
+ {createdAPIKey}
+
+ {
+ try {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ await navigator.clipboard.writeText(createdAPIKey);
+ } else {
+ // Fallback for non-HTTPS contexts
+ const textArea = document.createElement('textarea');
+ textArea.value = createdAPIKey;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-999999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ }
+ showToast('Key copied to clipboard');
+ } catch {
+ showToast('Failed to copy key', 'error');
+ }
+ }}
+ >
+
+
+
+ setCreatedAPIKey(null)}
+ >
+ Dismiss
+
+
+
+
+
+ )}
+
+ {/* Create Key Form */}
+ {showCreateAPIKey && (
+
+
+ Create New API Key
+
+
+
+
+ setNewAPIKeyName(e.target.value)}
+ placeholder="e.g., Home Assistant, OctoPrint"
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+ createAPIKeyMutation.mutate({
+ name: newAPIKeyName || 'Unnamed Key',
+ ...newAPIKeyPermissions,
+ })}
+ disabled={createAPIKeyMutation.isPending}
+ >
+ {createAPIKeyMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Create Key
+
+ setShowCreateAPIKey(false)}>
+ Cancel
+
+
+
+
+ )}
+
+ {/* Existing Keys List */}
+ {apiKeysLoading ? (
+
+
+
+ ) : apiKeys && apiKeys.length > 0 ? (
+
+ {apiKeys.map((key) => (
+
+
+
+
+
+
+ {key.name}
+
+ {key.key_prefix}••••••••
+ {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
+
+
+
+
+
+ {key.can_read_status && (
+ Read
+ )}
+ {key.can_queue && (
+ Queue
+ )}
+ {key.can_control_printer && (
+ Control
+ )}
+
+ setShowDeleteAPIKeyConfirm(key.id)}
+ >
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+ No API keys
+ Create an API key to integrate with external services.
+ setShowCreateAPIKey(true)}>
+
+ Create Your First Key
+
+
+
+
+ )}
+
+ {/* Webhook Documentation */}
+
+
+ Webhook Endpoints
+
+
+
+ Use your API key in the X-API-Key header.
+
+
+
+ GET{' '}
+ /api/v1/webhook/status
+ - Get all printer status
+
+
+ GET{' '}
+ /api/v1/webhook/status/:id
+ - Get specific printer status
+
+
+ POST{' '}
+ /api/v1/webhook/queue
+ - Add to print queue
+
+
+ POST{' '}
+ /api/v1/webhook/printer/:id/pause
+ - Pause print
+
+
+ POST{' '}
+ /api/v1/webhook/printer/:id/resume
+ - Resume print
+
+
+ POST{' '}
+ /api/v1/webhook/printer/:id/stop
+ - Stop print
+
+
+
+
+
+ )}
+
+ {/* Delete API Key Confirmation */}
+ {showDeleteAPIKeyConfirm !== null && (
+ {
+ deleteAPIKeyMutation.mutate(showDeleteAPIKeyConfirm);
+ setShowDeleteAPIKeyConfirm(null);
+ }}
+ onCancel={() => setShowDeleteAPIKeyConfirm(null)}
+ />
+ )}
+
{/* Smart Plug Modal */}
{showPlugModal && (
;
}
+function FailureAnalysisWidget() {
+ const { data: analysis, isLoading } = useQuery({
+ queryKey: ['failureAnalysis'],
+ queryFn: () => api.getFailureAnalysis({ days: 30 }),
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!analysis || analysis.total_prints === 0) {
+ return No print data in the last 30 days ;
+ }
+
+ const topReasons = Object.entries(analysis.failures_by_reason)
+ .sort(([, a], [, b]) => b - a)
+ .slice(0, 5);
+
+ return (
+
+ {/* Summary */}
+
+
+ 20 ? 'text-red-400' : analysis.failure_rate > 10 ? 'text-yellow-400' : 'text-bambu-green'}`} />
+ {analysis.failure_rate.toFixed(1)}%
+ failure rate
+
+
+ {analysis.failed_prints} / {analysis.total_prints} prints failed
+
+
+
+ {/* Top Failure Reasons */}
+ {topReasons.length > 0 && (
+
+ Top Failure Reasons
+ {topReasons.map(([reason, count]) => (
+
+ {reason || 'Unknown'}
+ {count}
+
+ ))}
+
+ )}
+
+ {/* Trend indicator */}
+ {analysis.trend && analysis.trend.length >= 2 && (
+
+
+
+
+ Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
+
+
+
+ )}
+
+ );
+}
+
export function StatsPage() {
+ const { showToast } = useToast();
+ const [isExporting, setIsExporting] = useState(false);
+ const [showExportMenu, setShowExportMenu] = useState(false);
+ const [dashboardKey, setDashboardKey] = useState(0);
+
const { data: stats, isLoading } = useQuery({
queryKey: ['archiveStats'],
queryFn: api.getArchiveStats,
@@ -332,6 +414,25 @@ export function StatsPage() {
queryFn: api.getSettings,
});
+ const handleExport = async (format: 'csv' | 'xlsx') => {
+ setShowExportMenu(false);
+ setIsExporting(true);
+ try {
+ const { blob, filename } = await api.exportStats({ format, days: 90 });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+ showToast('Export downloaded');
+ } catch (err) {
+ showToast('Export failed', 'error');
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
const currency = settings?.currency || '$';
const printerMap = new Map(printers?.map((p) => [String(p.id), p.name]) || []);
const printDates = archives?.map((a) => a.created_at) || [];
@@ -371,6 +472,12 @@ export function StatsPage() {
component: ,
defaultSize: 1,
},
+ {
+ id: 'failure-analysis',
+ title: 'Failure Analysis (30 days)',
+ component: ,
+ defaultSize: 1,
+ },
{
id: 'print-activity',
title: 'Print Activity',
@@ -391,14 +498,64 @@ export function StatsPage() {
},
];
+ const handleResetLayout = () => {
+ localStorage.removeItem('bambusy-dashboard-layout');
+ setDashboardKey(prev => prev + 1);
+ showToast('Layout reset');
+ };
+
return (
-
- Dashboard
- Drag widgets to rearrange. Click the eye icon to hide.
+
+
+ Dashboard
+ Drag widgets to rearrange. Click the eye icon to hide.
+
+
+
+
+ Reset Layout
+
+ {/* Export dropdown */}
+
+ setShowExportMenu(!showExportMenu)}
+ disabled={isExporting}
+ >
+ {isExporting ? (
+
+ ) : (
+
+ )}
+ Export Stats
+
+ {showExportMenu && (
+
+ handleExport('csv')}
+ >
+
+ Export as CSV
+
+ handleExport('xlsx')}
+ >
+
+ Export as Excel
+
+
+ )}
+
+
-
+
);
}
diff --git a/requirements.txt b/requirements.txt
index 1663663e8..e1349e2c3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,6 +17,9 @@ aioftp>=0.22.0
# 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
+# Excel Export
+openpyxl>=3.1.0
+
# Utilities
python-multipart>=0.0.6
aiofiles>=23.0.0
diff --git a/static/assets/index-BN5iZvNL.js b/static/assets/index-BN5iZvNL.js
deleted file mode 100644
index aeff7858d..000000000
--- a/static/assets/index-BN5iZvNL.js
+++ /dev/null
@@ -1,8611 +0,0 @@
-function jne(t,e){for(var n=0;n r[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const a of s.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&r(a)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var Wv=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function io(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var V2={exports:{}},Hg={};var wU;function Ine(){if(wU)return Hg;wU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var a=null;if(s!==void 0&&(a=""+s),i.key!==void 0&&(a=""+i.key),"key"in i){s={};for(var o in i)o!=="key"&&(s[o]=i[o])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:a,ref:i!==void 0?i:null,props:s}}return Hg.Fragment=e,Hg.jsx=n,Hg.jsxs=n,Hg}var MU;function Une(){return MU||(MU=1,V2.exports=Ine()),V2.exports}var c=Une(),G2={exports:{}},Qt={};var EU;function Fne(){if(EU)return Qt;EU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),a=Symbol.for("react.context"),o=Symbol.for("react.forward_ref"),l=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),h=Symbol.for("react.activity"),g=Symbol.iterator;function m(B){return B===null||typeof B!="object"?null:(B=g&&B[g]||B["@@iterator"],typeof B=="function"?B:null)}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},y=Object.assign,v={};function x(B,$,te){this.props=B,this.context=$,this.refs=v,this.updater=te||b}x.prototype.isReactComponent={},x.prototype.setState=function(B,$){if(typeof B!="object"&&typeof B!="function"&&B!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,B,$,"setState")},x.prototype.forceUpdate=function(B){this.updater.enqueueForceUpdate(this,B,"forceUpdate")};function M(){}M.prototype=x.prototype;function w(B,$,te){this.props=B,this.context=$,this.refs=v,this.updater=te||b}var A=w.prototype=new M;A.constructor=w,y(A,x.prototype),A.isPureReactComponent=!0;var T=Array.isArray;function R(){}var C={H:null,A:null,T:null,S:null},I=Object.prototype.hasOwnProperty;function N(B,$,te){var ne=te.ref;return{$$typeof:t,type:B,key:$,ref:ne!==void 0?ne:null,props:te}}function D(B,$){return N(B.type,$,B.props)}function F(B){return typeof B=="object"&&B!==null&&B.$$typeof===t}function j(B){var $={"=":"=0",":":"=2"};return"$"+B.replace(/[=:]/g,function(te){return $[te]})}var K=/\/+/g;function P(B,$){return typeof B=="object"&&B!==null&&B.key!=null?j(""+B.key):$.toString(36)}function V(B){switch(B.status){case"fulfilled":return B.value;case"rejected":throw B.reason;default:switch(typeof B.status=="string"?B.then(R,R):(B.status="pending",B.then(function($){B.status==="pending"&&(B.status="fulfilled",B.value=$)},function($){B.status==="pending"&&(B.status="rejected",B.reason=$)})),B.status){case"fulfilled":return B.value;case"rejected":throw B.reason}}throw B}function X(B,$,te,ne,ge){var ue=typeof B;(ue==="undefined"||ue==="boolean")&&(B=null);var ae=!1;if(B===null)ae=!0;else switch(ue){case"bigint":case"string":case"number":ae=!0;break;case"object":switch(B.$$typeof){case t:case e:ae=!0;break;case d:return ae=B._init,X(ae(B._payload),$,te,ne,ge)}}if(ae)return ge=ge(B),ae=ne===""?"."+P(B,0):ne,T(ge)?(te="",ae!=null&&(te=ae.replace(K,"$&/")+"/"),X(ge,$,te,"",function(Se){return Se})):ge!=null&&(F(ge)&&(ge=D(ge,te+(ge.key==null||B&&B.key===ge.key?"":(""+ge.key).replace(K,"$&/")+"/")+ae)),$.push(ge)),1;ae=0;var Me=ne===""?".":ne+":";if(T(B))for(var he=0;he>>1,Q=X[J];if(0>>1;Ji(te,Z))nei(ge,te)?(X[J]=ge,X[ne]=Z,J=ne):(X[J]=te,X[$]=Z,J=$);else if(nei(ge,Z))X[J]=ge,X[ne]=Z,J=ne;else break e}}return W}function i(X,W){var Z=X.sortIndex-W.sortIndex;return Z!==0?Z:X.id-W.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var a=Date,o=a.now();t.unstable_now=function(){return a.now()-o}}var l=[],u=[],d=1,h=null,g=3,m=!1,b=!1,y=!1,v=!1,x=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,w=typeof setImmediate<"u"?setImmediate:null;function A(X){for(var W=n(u);W!==null;){if(W.callback===null)r(u);else if(W.startTime<=X)r(u),W.sortIndex=W.expirationTime,e(l,W);else break;W=n(u)}}function T(X){if(y=!1,A(X),!b)if(n(l)!==null)b=!0,R||(R=!0,j());else{var W=n(u);W!==null&&V(T,W.startTime-X)}}var R=!1,C=-1,I=5,N=-1;function D(){return v?!0:!(t.unstable_now()-NX&&D());){var J=h.callback;if(typeof J=="function"){h.callback=null,g=h.priorityLevel;var Q=J(h.expirationTime<=X);if(X=t.unstable_now(),typeof Q=="function"){h.callback=Q,A(X),W=!0;break t}h===n(l)&&r(l),A(X)}else r(l);h=n(l)}if(h!==null)W=!0;else{var B=n(u);B!==null&&V(T,B.startTime-X),W=!1}}break e}finally{h=null,g=Z,m=!1}W=void 0}}finally{W?j():R=!1}}}var j;if(typeof w=="function")j=function(){w(F)};else if(typeof MessageChannel<"u"){var K=new MessageChannel,P=K.port2;K.port1.onmessage=F,j=function(){P.postMessage(null)}}else j=function(){x(F,0)};function V(X,W){C=x(function(){X(t.unstable_now())},W)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(X){X.callback=null},t.unstable_forceFrameRate=function(X){0>X||125J?(X.sortIndex=Z,e(u,X),n(l)===null&&X===n(u)&&(y?(M(C),C=-1):y=!0,V(T,Z-J))):(X.sortIndex=Q,e(l,X),b||m||(b=!0,R||(R=!0,j()))),X},t.unstable_shouldYield=D,t.unstable_wrapCallback=function(X){var W=g;return function(){var Z=g;g=W;try{return X.apply(this,arguments)}finally{g=Z}}}})(X2)),X2}var CU;function Hne(){return CU||(CU=1,q2.exports=Bne()),q2.exports}var K2={exports:{}},Ji={};var NU;function $ne(){if(NU)return Ji;NU=1;var t=Nm();function e(l){var u="https://react.dev/errors/"+l;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),K2.exports=$ne(),K2.exports}var RU;function Vne(){if(RU)return $g;RU=1;var t=Hne(),e=Nm(),n=U9();function r(f){var p="https://react.dev/errors/"+f;if(1Q||(f.current=J[Q],J[Q]=null,Q--)}function te(f,p){Q++,J[Q]=f.current,f.current=p}var ne=B(null),ge=B(null),ue=B(null),ae=B(null);function Me(f,p){switch(te(ue,p),te(ge,f),te(ne,null),p.nodeType){case 9:case 11:f=(f=p.documentElement)&&(f=f.namespaceURI)?W4(f):0;break;default:if(f=p.tagName,p=p.namespaceURI)p=W4(p),f=q4(p,f);else switch(f){case"svg":f=1;break;case"math":f=2;break;default:f=0}}$(ne),te(ne,f)}function he(){$(ne),$(ge),$(ue)}function Se(f){f.memoizedState!==null&&te(ae,f);var p=ne.current,_=q4(p,f.type);p!==_&&(te(ge,f),te(ne,_))}function Re(f){ge.current===f&&($(ne),$(ge)),ae.current===f&&($(ae),Ug._currentValue=Z)}var de,ct;function H(f){if(de===void 0)try{throw Error()}catch(_){var p=_.stack.trim().match(/\n( *(at )?)/);de=p&&p[1]||"",ct=-1<_.stack.indexOf(`
- at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return`
-`+de+f+ct}var z=!1;function ie(f,p){if(!f||z)return"";z=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var S={DetermineComponentFrameRoot:function(){try{if(p){var dt=function(){throw Error()};if(Object.defineProperty(dt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(dt,[])}catch(rt){var qe=rt}Reflect.construct(f,[],dt)}else{try{dt.call()}catch(rt){qe=rt}f.call(dt.prototype)}}else{try{throw Error()}catch(rt){qe=rt}(dt=f())&&typeof dt.catch=="function"&&dt.catch(function(){})}}catch(rt){if(rt&&qe&&typeof rt.stack=="string")return[rt.stack,qe.stack]}return[null,null]}};S.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var k=Object.getOwnPropertyDescriptor(S.DetermineComponentFrameRoot,"name");k&&k.configurable&&Object.defineProperty(S.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var O=S.DetermineComponentFrameRoot(),Y=O[0],oe=O[1];if(Y&&oe){var we=Y.split(`
-`),Ve=oe.split(`
-`);for(k=S=0;Sk||we[S]!==Ve[k]){var st=`
-`+we[S].replace(" at new "," at ");return f.displayName&&st.includes("")&&(st=st.replace("",f.displayName)),st}while(1<=S&&0<=k);break}}}finally{z=!1,Error.prepareStackTrace=_}return(_=f?f.displayName||f.name:"")?H(_):""}function G(f,p){switch(f.tag){case 26:case 27:case 5:return H(f.type);case 16:return H("Lazy");case 13:return f.child!==p&&p!==null?H("Suspense Fallback"):H("Suspense");case 19:return H("SuspenseList");case 0:case 15:return ie(f.type,!1);case 11:return ie(f.type.render,!1);case 1:return ie(f.type,!0);case 31:return H("Activity");default:return""}}function ee(f){try{var p="",_=null;do p+=G(f,_),_=f,f=f.return;while(f);return p}catch(S){return`
-Error generating stack: `+S.message+`
-`+S.stack}}var le=Object.prototype.hasOwnProperty,Te=t.unstable_scheduleCallback,Ae=t.unstable_cancelCallback,se=t.unstable_shouldYield,U=t.unstable_requestPaint,L=t.unstable_now,q=t.unstable_getCurrentPriorityLevel,me=t.unstable_ImmediatePriority,ve=t.unstable_UserBlockingPriority,xe=t.unstable_NormalPriority,Ye=t.unstable_LowPriority,Ie=t.unstable_IdlePriority,ze=t.log,ot=t.unstable_setDisableYieldValue,Fe=null,ce=null;function Je(f){if(typeof ze=="function"&&ot(f),ce&&typeof ce.setStrictMode=="function")try{ce.setStrictMode(Fe,f)}catch{}}var He=Math.clz32?Math.clz32:fe,Ze=Math.log,ut=Math.LN2;function fe(f){return f>>>=0,f===0?32:31-(Ze(f)/ut|0)|0}var et=256,be=262144,je=4194304;function Ce(f){var p=f&42;if(p!==0)return p;switch(f&-f){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return f&261888;case 262144:case 524288:case 1048576:case 2097152:return f&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return f&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return f}}function ye(f,p,_){var S=f.pendingLanes;if(S===0)return 0;var k=0,O=f.suspendedLanes,Y=f.pingedLanes;f=f.warmLanes;var oe=S&134217727;return oe!==0?(S=oe&~O,S!==0?k=Ce(S):(Y&=oe,Y!==0?k=Ce(Y):_||(_=oe&~f,_!==0&&(k=Ce(_))))):(oe=S&~O,oe!==0?k=Ce(oe):Y!==0?k=Ce(Y):_||(_=S&~f,_!==0&&(k=Ce(_)))),k===0?0:p!==0&&p!==k&&(p&O)===0&&(O=k&-k,_=p&-p,O>=_||O===32&&(_&4194048)!==0)?p:k}function Ge(f,p){return(f.pendingLanes&~(f.suspendedLanes&~f.pingedLanes)&p)===0}function ft(f,p){switch(f){case 1:case 2:case 4:case 8:case 64:return p+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return p+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function yt(){var f=je;return je<<=1,(je&62914560)===0&&(je=4194304),f}function Pt(f){for(var p=[],_=0;31>_;_++)p.push(f);return p}function cn(f,p){f.pendingLanes|=p,p!==268435456&&(f.suspendedLanes=0,f.pingedLanes=0,f.warmLanes=0)}function jt(f,p,_,S,k,O){var Y=f.pendingLanes;f.pendingLanes=_,f.suspendedLanes=0,f.pingedLanes=0,f.warmLanes=0,f.expiredLanes&=_,f.entangledLanes&=_,f.errorRecoveryDisabledLanes&=_,f.shellSuspendCounter=0;var oe=f.entanglements,we=f.expirationTimes,Ve=f.hiddenUpdates;for(_=Y&~_;0<_;){var st=31-He(_),dt=1<"u")return null;try{return f.activeElement||f.body}catch{return f.body}}var kr=/[\n"\\]/g;function kn(f){return f.replace(kr,function(p){return"\\"+p.charCodeAt(0).toString(16)+" "})}function oo(f,p,_,S,k,O,Y,oe){f.name="",Y!=null&&typeof Y!="function"&&typeof Y!="symbol"&&typeof Y!="boolean"?f.type=Y:f.removeAttribute("type"),p!=null?Y==="number"?(p===0&&f.value===""||f.value!=p)&&(f.value=""+dn(p)):f.value!==""+dn(p)&&(f.value=""+dn(p)):Y!=="submit"&&Y!=="reset"||f.removeAttribute("value"),p!=null?yi(f,Y,dn(p)):_!=null?yi(f,Y,dn(_)):S!=null&&f.removeAttribute("value"),k==null&&O!=null&&(f.defaultChecked=!!O),k!=null&&(f.checked=k&&typeof k!="function"&&typeof k!="symbol"),oe!=null&&typeof oe!="function"&&typeof oe!="symbol"&&typeof oe!="boolean"?f.name=""+dn(oe):f.removeAttribute("name")}function Pn(f,p,_,S,k,O,Y,oe){if(O!=null&&typeof O!="function"&&typeof O!="symbol"&&typeof O!="boolean"&&(f.type=O),p!=null||_!=null){if(!(O!=="submit"&&O!=="reset"||p!=null)){Zt(f);return}_=_!=null?""+dn(_):"",p=p!=null?""+dn(p):_,oe||p===f.value||(f.value=p),f.defaultValue=p}S=S??k,S=typeof S!="function"&&typeof S!="symbol"&&!!S,f.checked=oe?f.checked:!!S,f.defaultChecked=!!S,Y!=null&&typeof Y!="function"&&typeof Y!="symbol"&&typeof Y!="boolean"&&(f.name=Y),Zt(f)}function yi(f,p,_){p==="number"&&Sr(f.ownerDocument)===f||f.defaultValue===""+_||(f.defaultValue=""+_)}function ri(f,p,_,S){if(f=f.options,p){p={};for(var k=0;k<_.length;k++)p["$"+_[k]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),VM=!1;if(yl)try{var ng={};Object.defineProperty(ng,"passive",{get:function(){VM=!0}}),window.addEventListener("test",ng,ng),window.removeEventListener("test",ng,ng)}catch{VM=!1}var Uc=null,GM=null,Ub=null;function qL(){if(Ub)return Ub;var f,p=GM,_=p.length,S,k="value"in Uc?Uc.value:Uc.textContent,O=k.length;for(f=0;f<_&&p[f]===k[f];f++);var Y=_-f;for(S=1;S<=Y&&p[_-S]===k[O-S];S++);return Ub=k.slice(f,1=sg),JL=" ",ej=!1;function tj(f,p){switch(f){case"keyup":return ite.indexOf(p.keyCode)!==-1;case"keydown":return p.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function nj(f){return f=f.detail,typeof f=="object"&&"data"in f?f.data:null}var ah=!1;function ate(f,p){switch(f){case"compositionend":return nj(p);case"keypress":return p.which!==32?null:(ej=!0,JL);case"textInput":return f=p.data,f===JL&&ej?null:f;default:return null}}function ote(f,p){if(ah)return f==="compositionend"||!YM&&tj(f,p)?(f=qL(),Ub=GM=Uc=null,ah=!1,f):null;switch(f){case"paste":return null;case"keypress":if(!(p.ctrlKey||p.altKey||p.metaKey)||p.ctrlKey&&p.altKey){if(p.char&&1=p)return{node:_,offset:p-f};f=S}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=uj(_)}}function fj(f,p){return f&&p?f===p?!0:f&&f.nodeType===3?!1:p&&p.nodeType===3?fj(f,p.parentNode):"contains"in f?f.contains(p):f.compareDocumentPosition?!!(f.compareDocumentPosition(p)&16):!1:!1}function hj(f){f=f!=null&&f.ownerDocument!=null&&f.ownerDocument.defaultView!=null?f.ownerDocument.defaultView:window;for(var p=Sr(f.document);p instanceof f.HTMLIFrameElement;){try{var _=typeof p.contentWindow.location.href=="string"}catch{_=!1}if(_)f=p.contentWindow;else break;p=Sr(f.document)}return p}function JM(f){var p=f&&f.nodeName&&f.nodeName.toLowerCase();return p&&(p==="input"&&(f.type==="text"||f.type==="search"||f.type==="tel"||f.type==="url"||f.type==="password")||p==="textarea"||f.contentEditable==="true")}var mte=yl&&"documentMode"in document&&11>=document.documentMode,oh=null,eE=null,cg=null,tE=!1;function pj(f,p,_){var S=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;tE||oh==null||oh!==Sr(S)||(S=oh,"selectionStart"in S&&JM(S)?S={start:S.selectionStart,end:S.selectionEnd}:(S=(S.ownerDocument&&S.ownerDocument.defaultView||window).getSelection(),S={anchorNode:S.anchorNode,anchorOffset:S.anchorOffset,focusNode:S.focusNode,focusOffset:S.focusOffset}),cg&&lg(cg,S)||(cg=S,S=Rv(eE,"onSelect"),0>=Y,k-=Y,zo=1<<32-He(p)+k|_<tn?(bn=Ot,Ot=null):bn=Ot.sibling;var En=qe(Le,Ot,$e[tn],lt);if(En===null){Ot===null&&(Ot=bn);break}f&&Ot&&En.alternate===null&&p(Le,Ot),Ne=O(En,Ne,tn),Mn===null?zt=En:Mn.sibling=En,Mn=En,Ot=bn}if(tn===$e.length)return _(Le,Ot),yn&&Sl(Le,tn),zt;if(Ot===null){for(;tn<$e.length;tn++)Ot=dt(Le,$e[tn],lt),Ot!==null&&(Ne=O(Ot,Ne,tn),Mn===null?zt=Ot:Mn.sibling=Ot,Mn=Ot);return yn&&Sl(Le,tn),zt}for(Ot=S(Ot);tn<$e.length;tn++)bn=rt(Ot,Le,tn,$e[tn],lt),bn!==null&&(f&&bn.alternate!==null&&Ot.delete(bn.key===null?tn:bn.key),Ne=O(bn,Ne,tn),Mn===null?zt=bn:Mn.sibling=bn,Mn=bn);return f&&Ot.forEach(function(au){return p(Le,au)}),yn&&Sl(Le,tn),zt}function Vt(Le,Ne,$e,lt){if($e==null)throw Error(r(151));for(var zt=null,Mn=null,Ot=Ne,tn=Ne=0,bn=null,En=$e.next();Ot!==null&&!En.done;tn++,En=$e.next()){Ot.index>tn?(bn=Ot,Ot=null):bn=Ot.sibling;var au=qe(Le,Ot,En.value,lt);if(au===null){Ot===null&&(Ot=bn);break}f&&Ot&&au.alternate===null&&p(Le,Ot),Ne=O(au,Ne,tn),Mn===null?zt=au:Mn.sibling=au,Mn=au,Ot=bn}if(En.done)return _(Le,Ot),yn&&Sl(Le,tn),zt;if(Ot===null){for(;!En.done;tn++,En=$e.next())En=dt(Le,En.value,lt),En!==null&&(Ne=O(En,Ne,tn),Mn===null?zt=En:Mn.sibling=En,Mn=En);return yn&&Sl(Le,tn),zt}for(Ot=S(Ot);!En.done;tn++,En=$e.next())En=rt(Ot,Le,tn,En.value,lt),En!==null&&(f&&En.alternate!==null&&Ot.delete(En.key===null?tn:En.key),Ne=O(En,Ne,tn),Mn===null?zt=En:Mn.sibling=En,Mn=En);return f&&Ot.forEach(function(Lne){return p(Le,Lne)}),yn&&Sl(Le,tn),zt}function zn(Le,Ne,$e,lt){if(typeof $e=="object"&&$e!==null&&$e.type===y&&$e.key===null&&($e=$e.props.children),typeof $e=="object"&&$e!==null){switch($e.$$typeof){case m:e:{for(var zt=$e.key;Ne!==null;){if(Ne.key===zt){if(zt=$e.type,zt===y){if(Ne.tag===7){_(Le,Ne.sibling),lt=k(Ne,$e.props.children),lt.return=Le,Le=lt;break e}}else if(Ne.elementType===zt||typeof zt=="object"&&zt!==null&&zt.$$typeof===I&&Nd(zt)===Ne.type){_(Le,Ne.sibling),lt=k(Ne,$e.props),mg(lt,$e),lt.return=Le,Le=lt;break e}_(Le,Ne);break}else p(Le,Ne);Ne=Ne.sibling}$e.type===y?(lt=Md($e.props.children,Le.mode,lt,$e.key),lt.return=Le,Le=lt):(lt=Xb($e.type,$e.key,$e.props,null,Le.mode,lt),mg(lt,$e),lt.return=Le,Le=lt)}return Y(Le);case b:e:{for(zt=$e.key;Ne!==null;){if(Ne.key===zt)if(Ne.tag===4&&Ne.stateNode.containerInfo===$e.containerInfo&&Ne.stateNode.implementation===$e.implementation){_(Le,Ne.sibling),lt=k(Ne,$e.children||[]),lt.return=Le,Le=lt;break e}else{_(Le,Ne);break}else p(Le,Ne);Ne=Ne.sibling}lt=lE($e,Le.mode,lt),lt.return=Le,Le=lt}return Y(Le);case I:return $e=Nd($e),zn(Le,Ne,$e,lt)}if(V($e))return kt(Le,Ne,$e,lt);if(j($e)){if(zt=j($e),typeof zt!="function")throw Error(r(150));return $e=zt.call($e),Vt(Le,Ne,$e,lt)}if(typeof $e.then=="function")return zn(Le,Ne,tv($e),lt);if($e.$$typeof===w)return zn(Le,Ne,Zb(Le,$e),lt);nv(Le,$e)}return typeof $e=="string"&&$e!==""||typeof $e=="number"||typeof $e=="bigint"?($e=""+$e,Ne!==null&&Ne.tag===6?(_(Le,Ne.sibling),lt=k(Ne,$e),lt.return=Le,Le=lt):(_(Le,Ne),lt=oE($e,Le.mode,lt),lt.return=Le,Le=lt),Y(Le)):_(Le,Ne)}return function(Le,Ne,$e,lt){try{pg=0;var zt=zn(Le,Ne,$e,lt);return bh=null,zt}catch(Ot){if(Ot===xh||Ot===Jb)throw Ot;var Mn=ea(29,Ot,null,Le.mode);return Mn.lanes=lt,Mn.return=Le,Mn}finally{}}}var Rd=Ij(!0),Uj=Ij(!1),$c=!1;function yE(f){f.updateQueue={baseState:f.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function _E(f,p){f=f.updateQueue,p.updateQueue===f&&(p.updateQueue={baseState:f.baseState,firstBaseUpdate:f.firstBaseUpdate,lastBaseUpdate:f.lastBaseUpdate,shared:f.shared,callbacks:null})}function Vc(f){return{lane:f,tag:0,payload:null,callback:null,next:null}}function Gc(f,p,_){var S=f.updateQueue;if(S===null)return null;if(S=S.shared,(Nn&2)!==0){var k=S.pending;return k===null?p.next=p:(p.next=k.next,k.next=p),S.pending=p,p=qb(f),_j(f,null,_),p}return Wb(f,S,p,_),qb(f)}function gg(f,p,_){if(p=p.updateQueue,p!==null&&(p=p.shared,(_&4194048)!==0)){var S=p.lanes;S&=f.pendingLanes,_|=S,p.lanes=_,Xn(f,_)}}function SE(f,p){var _=f.updateQueue,S=f.alternate;if(S!==null&&(S=S.updateQueue,_===S)){var k=null,O=null;if(_=_.firstBaseUpdate,_!==null){do{var Y={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};O===null?k=O=Y:O=O.next=Y,_=_.next}while(_!==null);O===null?k=O=p:O=O.next=p}else k=O=p;_={baseState:S.baseState,firstBaseUpdate:k,lastBaseUpdate:O,shared:S.shared,callbacks:S.callbacks},f.updateQueue=_;return}f=_.lastBaseUpdate,f===null?_.firstBaseUpdate=p:f.next=p,_.lastBaseUpdate=p}var wE=!1;function xg(){if(wE){var f=gh;if(f!==null)throw f}}function bg(f,p,_,S){wE=!1;var k=f.updateQueue;$c=!1;var O=k.firstBaseUpdate,Y=k.lastBaseUpdate,oe=k.shared.pending;if(oe!==null){k.shared.pending=null;var we=oe,Ve=we.next;we.next=null,Y===null?O=Ve:Y.next=Ve,Y=we;var st=f.alternate;st!==null&&(st=st.updateQueue,oe=st.lastBaseUpdate,oe!==Y&&(oe===null?st.firstBaseUpdate=Ve:oe.next=Ve,st.lastBaseUpdate=we))}if(O!==null){var dt=k.baseState;Y=0,st=Ve=we=null,oe=O;do{var qe=oe.lane&-536870913,rt=qe!==oe.lane;if(rt?(xn&qe)===qe:(S&qe)===qe){qe!==0&&qe===mh&&(wE=!0),st!==null&&(st=st.next={lane:0,tag:oe.tag,payload:oe.payload,callback:null,next:null});e:{var kt=f,Vt=oe;qe=p;var zn=_;switch(Vt.tag){case 1:if(kt=Vt.payload,typeof kt=="function"){dt=kt.call(zn,dt,qe);break e}dt=kt;break e;case 3:kt.flags=kt.flags&-65537|128;case 0:if(kt=Vt.payload,qe=typeof kt=="function"?kt.call(zn,dt,qe):kt,qe==null)break e;dt=h({},dt,qe);break e;case 2:$c=!0}}qe=oe.callback,qe!==null&&(f.flags|=64,rt&&(f.flags|=8192),rt=k.callbacks,rt===null?k.callbacks=[qe]:rt.push(qe))}else rt={lane:qe,tag:oe.tag,payload:oe.payload,callback:oe.callback,next:null},st===null?(Ve=st=rt,we=dt):st=st.next=rt,Y|=qe;if(oe=oe.next,oe===null){if(oe=k.shared.pending,oe===null)break;rt=oe,oe=rt.next,rt.next=null,k.lastBaseUpdate=rt,k.shared.pending=null}}while(!0);st===null&&(we=dt),k.baseState=we,k.firstBaseUpdate=Ve,k.lastBaseUpdate=st,O===null&&(k.shared.lanes=0),Yc|=Y,f.lanes=Y,f.memoizedState=dt}}function Fj(f,p){if(typeof f!="function")throw Error(r(191,f));f.call(p)}function zj(f,p){var _=f.callbacks;if(_!==null)for(f.callbacks=null,f=0;f<_.length;f++)Fj(_[f],p)}var vh=B(null),rv=B(0);function Bj(f,p){f=Dl,te(rv,f),te(vh,p),Dl=f|p.baseLanes}function ME(){te(rv,Dl),te(vh,vh.current)}function EE(){Dl=rv.current,$(vh),$(rv)}var ta=B(null),Ca=null;function Wc(f){var p=f.alternate;te(Rr,Rr.current&1),te(ta,f),Ca===null&&(p===null||vh.current!==null||p.memoizedState!==null)&&(Ca=f)}function AE(f){te(Rr,Rr.current),te(ta,f),Ca===null&&(Ca=f)}function Hj(f){f.tag===22?(te(Rr,Rr.current),te(ta,f),Ca===null&&(Ca=f)):qc()}function qc(){te(Rr,Rr.current),te(ta,ta.current)}function na(f){$(ta),Ca===f&&(Ca=null),$(Rr)}var Rr=B(0);function iv(f){for(var p=f;p!==null;){if(p.tag===13){var _=p.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||P2(_)||D2(_)))return p}else if(p.tag===19&&(p.memoizedProps.revealOrder==="forwards"||p.memoizedProps.revealOrder==="backwards"||p.memoizedProps.revealOrder==="unstable_legacy-backwards"||p.memoizedProps.revealOrder==="together")){if((p.flags&128)!==0)return p}else if(p.child!==null){p.child.return=p,p=p.child;continue}if(p===f)break;for(;p.sibling===null;){if(p.return===null||p.return===f)return null;p=p.return}p.sibling.return=p.return,p=p.sibling}return null}var El=0,en=null,Un=null,Fr=null,sv=!1,yh=!1,Pd=!1,av=0,vg=0,_h=null,Ete=0;function wr(){throw Error(r(321))}function TE(f,p){if(p===null)return!1;for(var _=0;_O?O:8;var Y=X.T,oe={};X.T=oe,HE(f,!1,p,_);try{var we=k(),Ve=X.S;if(Ve!==null&&Ve(oe,we),we!==null&&typeof we=="object"&&typeof we.then=="function"){var st=Mte(we,S);_g(f,p,st,sa(f))}else _g(f,p,S,sa(f))}catch(dt){_g(f,p,{then:function(){},status:"rejected",reason:dt},sa())}finally{W.p=O,Y!==null&&oe.types!==null&&(Y.types=oe.types),X.T=Y}}function kte(){}function zE(f,p,_,S){if(f.tag!==5)throw Error(r(476));var k=bI(f).queue;xI(f,k,p,Z,_===null?kte:function(){return vI(f),_(S)})}function bI(f){var p=f.memoizedState;if(p!==null)return p;p={memoizedState:Z,baseState:Z,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Al,lastRenderedState:Z},next:null};var _={};return p.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Al,lastRenderedState:_},next:null},f.memoizedState=p,f=f.alternate,f!==null&&(f.memoizedState=p),p}function vI(f){var p=bI(f);p.next===null&&(p=f.alternate.memoizedState),_g(f,p.next.queue,{},sa())}function BE(){return Fi(Ug)}function yI(){return Pr().memoizedState}function _I(){return Pr().memoizedState}function Rte(f){for(var p=f.return;p!==null;){switch(p.tag){case 24:case 3:var _=sa();f=Vc(_);var S=Gc(p,f,_);S!==null&&(js(S,p,_),gg(S,p,_)),p={cache:gE()},f.payload=p;return}p=p.return}}function Pte(f,p,_){var S=sa();_={lane:S,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},fv(f)?wI(p,_):(_=sE(f,p,_,S),_!==null&&(js(_,f,S),MI(_,p,S)))}function SI(f,p,_){var S=sa();_g(f,p,_,S)}function _g(f,p,_,S){var k={lane:S,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(fv(f))wI(p,k);else{var O=f.alternate;if(f.lanes===0&&(O===null||O.lanes===0)&&(O=p.lastRenderedReducer,O!==null))try{var Y=p.lastRenderedState,oe=O(Y,_);if(k.hasEagerState=!0,k.eagerState=oe,Js(oe,Y))return Wb(f,p,k,0),Kn===null&&Gb(),!1}catch{}finally{}if(_=sE(f,p,k,S),_!==null)return js(_,f,S),MI(_,p,S),!0}return!1}function HE(f,p,_,S){if(S={lane:2,revertLane:y2(),gesture:null,action:S,hasEagerState:!1,eagerState:null,next:null},fv(f)){if(p)throw Error(r(479))}else p=sE(f,_,S,2),p!==null&&js(p,f,2)}function fv(f){var p=f.alternate;return f===en||p!==null&&p===en}function wI(f,p){yh=sv=!0;var _=f.pending;_===null?p.next=p:(p.next=_.next,_.next=p),f.pending=p}function MI(f,p,_){if((_&4194048)!==0){var S=p.lanes;S&=f.pendingLanes,_|=S,p.lanes=_,Xn(f,_)}}var Sg={readContext:Fi,use:lv,useCallback:wr,useContext:wr,useEffect:wr,useImperativeHandle:wr,useLayoutEffect:wr,useInsertionEffect:wr,useMemo:wr,useReducer:wr,useRef:wr,useState:wr,useDebugValue:wr,useDeferredValue:wr,useTransition:wr,useSyncExternalStore:wr,useId:wr,useHostTransitionStatus:wr,useFormState:wr,useActionState:wr,useOptimistic:wr,useMemoCache:wr,useCacheRefresh:wr};Sg.useEffectEvent=wr;var EI={readContext:Fi,use:lv,useCallback:function(f,p){return ms().memoizedState=[f,p===void 0?null:p],f},useContext:Fi,useEffect:lI,useImperativeHandle:function(f,p,_){_=_!=null?_.concat([f]):null,uv(4194308,4,fI.bind(null,p,f),_)},useLayoutEffect:function(f,p){return uv(4194308,4,f,p)},useInsertionEffect:function(f,p){uv(4,2,f,p)},useMemo:function(f,p){var _=ms();p=p===void 0?null:p;var S=f();if(Pd){Je(!0);try{f()}finally{Je(!1)}}return _.memoizedState=[S,p],S},useReducer:function(f,p,_){var S=ms();if(_!==void 0){var k=_(p);if(Pd){Je(!0);try{_(p)}finally{Je(!1)}}}else k=p;return S.memoizedState=S.baseState=k,f={pending:null,lanes:0,dispatch:null,lastRenderedReducer:f,lastRenderedState:k},S.queue=f,f=f.dispatch=Pte.bind(null,en,f),[S.memoizedState,f]},useRef:function(f){var p=ms();return f={current:f},p.memoizedState=f},useState:function(f){f=LE(f);var p=f.queue,_=SI.bind(null,en,p);return p.dispatch=_,[f.memoizedState,_]},useDebugValue:UE,useDeferredValue:function(f,p){var _=ms();return FE(_,f,p)},useTransition:function(){var f=LE(!1);return f=xI.bind(null,en,f.queue,!0,!1),ms().memoizedState=f,[!1,f]},useSyncExternalStore:function(f,p,_){var S=en,k=ms();if(yn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=p(),Kn===null)throw Error(r(349));(xn&127)!==0||Wj(S,p,_)}k.memoizedState=_;var O={value:_,getSnapshot:p};return k.queue=O,lI(Xj.bind(null,S,O,f),[f]),S.flags|=2048,Sh(9,{destroy:void 0},qj.bind(null,S,O,_,p),null),_},useId:function(){var f=ms(),p=Kn.identifierPrefix;if(yn){var _=Bo,S=zo;_=(S&~(1<<32-He(S)-1)).toString(32)+_,p="_"+p+"R_"+_,_=av++,0<_&&(p+="H"+_.toString(32)),p+="_"}else _=Ete++,p="_"+p+"r_"+_.toString(32)+"_";return f.memoizedState=p},useHostTransitionStatus:BE,useFormState:rI,useActionState:rI,useOptimistic:function(f){var p=ms();p.memoizedState=p.baseState=f;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return p.queue=_,p=HE.bind(null,en,!0,_),_.dispatch=p,[f,p]},useMemoCache:PE,useCacheRefresh:function(){return ms().memoizedState=Rte.bind(null,en)},useEffectEvent:function(f){var p=ms(),_={impl:f};return p.memoizedState=_,function(){if((Nn&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},$E={readContext:Fi,use:lv,useCallback:pI,useContext:Fi,useEffect:IE,useImperativeHandle:hI,useInsertionEffect:uI,useLayoutEffect:dI,useMemo:mI,useReducer:cv,useRef:oI,useState:function(){return cv(Al)},useDebugValue:UE,useDeferredValue:function(f,p){var _=Pr();return gI(_,Un.memoizedState,f,p)},useTransition:function(){var f=cv(Al)[0],p=Pr().memoizedState;return[typeof f=="boolean"?f:yg(f),p]},useSyncExternalStore:Gj,useId:yI,useHostTransitionStatus:BE,useFormState:iI,useActionState:iI,useOptimistic:function(f,p){var _=Pr();return Zj(_,Un,f,p)},useMemoCache:PE,useCacheRefresh:_I};$E.useEffectEvent=cI;var AI={readContext:Fi,use:lv,useCallback:pI,useContext:Fi,useEffect:IE,useImperativeHandle:hI,useInsertionEffect:uI,useLayoutEffect:dI,useMemo:mI,useReducer:OE,useRef:oI,useState:function(){return OE(Al)},useDebugValue:UE,useDeferredValue:function(f,p){var _=Pr();return Un===null?FE(_,f,p):gI(_,Un.memoizedState,f,p)},useTransition:function(){var f=OE(Al)[0],p=Pr().memoizedState;return[typeof f=="boolean"?f:yg(f),p]},useSyncExternalStore:Gj,useId:yI,useHostTransitionStatus:BE,useFormState:aI,useActionState:aI,useOptimistic:function(f,p){var _=Pr();return Un!==null?Zj(_,Un,f,p):(_.baseState=f,[f,_.queue.dispatch])},useMemoCache:PE,useCacheRefresh:_I};AI.useEffectEvent=cI;function VE(f,p,_,S){p=f.memoizedState,_=_(S,p),_=_==null?p:h({},p,_),f.memoizedState=_,f.lanes===0&&(f.updateQueue.baseState=_)}var GE={enqueueSetState:function(f,p,_){f=f._reactInternals;var S=sa(),k=Vc(S);k.payload=p,_!=null&&(k.callback=_),p=Gc(f,k,S),p!==null&&(js(p,f,S),gg(p,f,S))},enqueueReplaceState:function(f,p,_){f=f._reactInternals;var S=sa(),k=Vc(S);k.tag=1,k.payload=p,_!=null&&(k.callback=_),p=Gc(f,k,S),p!==null&&(js(p,f,S),gg(p,f,S))},enqueueForceUpdate:function(f,p){f=f._reactInternals;var _=sa(),S=Vc(_);S.tag=2,p!=null&&(S.callback=p),p=Gc(f,S,_),p!==null&&(js(p,f,_),gg(p,f,_))}};function TI(f,p,_,S,k,O,Y){return f=f.stateNode,typeof f.shouldComponentUpdate=="function"?f.shouldComponentUpdate(S,O,Y):p.prototype&&p.prototype.isPureReactComponent?!lg(_,S)||!lg(k,O):!0}function CI(f,p,_,S){f=p.state,typeof p.componentWillReceiveProps=="function"&&p.componentWillReceiveProps(_,S),typeof p.UNSAFE_componentWillReceiveProps=="function"&&p.UNSAFE_componentWillReceiveProps(_,S),p.state!==f&&GE.enqueueReplaceState(p,p.state,null)}function Dd(f,p){var _=p;if("ref"in p){_={};for(var S in p)S!=="ref"&&(_[S]=p[S])}if(f=f.defaultProps){_===p&&(_=h({},_));for(var k in f)_[k]===void 0&&(_[k]=f[k])}return _}function NI(f){Vb(f)}function kI(f){console.error(f)}function RI(f){Vb(f)}function hv(f,p){try{var _=f.onUncaughtError;_(p.value,{componentStack:p.stack})}catch(S){setTimeout(function(){throw S})}}function PI(f,p,_){try{var S=f.onCaughtError;S(_.value,{componentStack:_.stack,errorBoundary:p.tag===1?p.stateNode:null})}catch(k){setTimeout(function(){throw k})}}function WE(f,p,_){return _=Vc(_),_.tag=3,_.payload={element:null},_.callback=function(){hv(f,p)},_}function DI(f){return f=Vc(f),f.tag=3,f}function OI(f,p,_,S){var k=_.type.getDerivedStateFromError;if(typeof k=="function"){var O=S.value;f.payload=function(){return k(O)},f.callback=function(){PI(p,_,S)}}var Y=_.stateNode;Y!==null&&typeof Y.componentDidCatch=="function"&&(f.callback=function(){PI(p,_,S),typeof k!="function"&&(Zc===null?Zc=new Set([this]):Zc.add(this));var oe=S.stack;this.componentDidCatch(S.value,{componentStack:oe!==null?oe:""})})}function Dte(f,p,_,S,k){if(_.flags|=32768,S!==null&&typeof S=="object"&&typeof S.then=="function"){if(p=_.alternate,p!==null&&ph(p,_,k,!0),_=ta.current,_!==null){switch(_.tag){case 31:case 13:return Ca===null?Ev():_.alternate===null&&Mr===0&&(Mr=3),_.flags&=-257,_.flags|=65536,_.lanes=k,S===ev?_.flags|=16384:(p=_.updateQueue,p===null?_.updateQueue=new Set([S]):p.add(S),x2(f,S,k)),!1;case 22:return _.flags|=65536,S===ev?_.flags|=16384:(p=_.updateQueue,p===null?(p={transitions:null,markerInstances:null,retryQueue:new Set([S])},_.updateQueue=p):(_=p.retryQueue,_===null?p.retryQueue=new Set([S]):_.add(S)),x2(f,S,k)),!1}throw Error(r(435,_.tag))}return x2(f,S,k),Ev(),!1}if(yn)return p=ta.current,p!==null?((p.flags&65536)===0&&(p.flags|=256),p.flags|=65536,p.lanes=k,S!==dE&&(f=Error(r(422),{cause:S}),dg(Ma(f,_)))):(S!==dE&&(p=Error(r(423),{cause:S}),dg(Ma(p,_))),f=f.current.alternate,f.flags|=65536,k&=-k,f.lanes|=k,S=Ma(S,_),k=WE(f.stateNode,S,k),SE(f,k),Mr!==4&&(Mr=2)),!1;var O=Error(r(520),{cause:S});if(O=Ma(O,_),kg===null?kg=[O]:kg.push(O),Mr!==4&&(Mr=2),p===null)return!0;S=Ma(S,_),_=p;do{switch(_.tag){case 3:return _.flags|=65536,f=k&-k,_.lanes|=f,f=WE(_.stateNode,S,f),SE(_,f),!1;case 1:if(p=_.type,O=_.stateNode,(_.flags&128)===0&&(typeof p.getDerivedStateFromError=="function"||O!==null&&typeof O.componentDidCatch=="function"&&(Zc===null||!Zc.has(O))))return _.flags|=65536,k&=-k,_.lanes|=k,k=DI(k),OI(k,f,_,S),SE(_,k),!1}_=_.return}while(_!==null);return!1}var qE=Error(r(461)),zr=!1;function zi(f,p,_,S){p.child=f===null?Uj(p,null,_,S):Rd(p,f.child,_,S)}function LI(f,p,_,S,k){_=_.render;var O=p.ref;if("ref"in S){var Y={};for(var oe in S)oe!=="ref"&&(Y[oe]=S[oe])}else Y=S;return Td(p),S=CE(f,p,_,Y,O,k),oe=NE(),f!==null&&!zr?(kE(f,p,k),Tl(f,p,k)):(yn&&oe&&cE(p),p.flags|=1,zi(f,p,S,k),p.child)}function jI(f,p,_,S,k){if(f===null){var O=_.type;return typeof O=="function"&&!aE(O)&&O.defaultProps===void 0&&_.compare===null?(p.tag=15,p.type=O,II(f,p,O,S,k)):(f=Xb(_.type,null,S,p,p.mode,k),f.ref=p.ref,f.return=p,p.child=f)}if(O=f.child,!t2(f,k)){var Y=O.memoizedProps;if(_=_.compare,_=_!==null?_:lg,_(Y,S)&&f.ref===p.ref)return Tl(f,p,k)}return p.flags|=1,f=_l(O,S),f.ref=p.ref,f.return=p,p.child=f}function II(f,p,_,S,k){if(f!==null){var O=f.memoizedProps;if(lg(O,S)&&f.ref===p.ref)if(zr=!1,p.pendingProps=S=O,t2(f,k))(f.flags&131072)!==0&&(zr=!0);else return p.lanes=f.lanes,Tl(f,p,k)}return XE(f,p,_,S,k)}function UI(f,p,_,S){var k=S.children,O=f!==null?f.memoizedState:null;if(f===null&&p.stateNode===null&&(p.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),S.mode==="hidden"){if((p.flags&128)!==0){if(O=O!==null?O.baseLanes|_:_,f!==null){for(S=p.child=f.child,k=0;S!==null;)k=k|S.lanes|S.childLanes,S=S.sibling;S=k&~O}else S=0,p.child=null;return FI(f,p,O,_,S)}if((_&536870912)!==0)p.memoizedState={baseLanes:0,cachePool:null},f!==null&&Qb(p,O!==null?O.cachePool:null),O!==null?Bj(p,O):ME(),Hj(p);else return S=p.lanes=536870912,FI(f,p,O!==null?O.baseLanes|_:_,_,S)}else O!==null?(Qb(p,O.cachePool),Bj(p,O),qc(),p.memoizedState=null):(f!==null&&Qb(p,null),ME(),qc());return zi(f,p,k,_),p.child}function wg(f,p){return f!==null&&f.tag===22||p.stateNode!==null||(p.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),p.sibling}function FI(f,p,_,S,k){var O=bE();return O=O===null?null:{parent:Ur._currentValue,pool:O},p.memoizedState={baseLanes:_,cachePool:O},f!==null&&Qb(p,null),ME(),Hj(p),f!==null&&ph(f,p,S,!0),p.childLanes=k,null}function pv(f,p){return p=gv({mode:p.mode,children:p.children},f.mode),p.ref=f.ref,f.child=p,p.return=f,p}function zI(f,p,_){return Rd(p,f.child,null,_),f=pv(p,p.pendingProps),f.flags|=2,na(p),p.memoizedState=null,f}function Ote(f,p,_){var S=p.pendingProps,k=(p.flags&128)!==0;if(p.flags&=-129,f===null){if(yn){if(S.mode==="hidden")return f=pv(p,S),p.lanes=536870912,wg(null,f);if(AE(p),(f=rr)?(f=Q4(f,Ta),f=f!==null&&f.data==="&"?f:null,f!==null&&(p.memoizedState={dehydrated:f,treeContext:Fc!==null?{id:zo,overflow:Bo}:null,retryLane:536870912,hydrationErrors:null},_=wj(f),_.return=p,p.child=_,Ui=p,rr=null)):f=null,f===null)throw Bc(p);return p.lanes=536870912,null}return pv(p,S)}var O=f.memoizedState;if(O!==null){var Y=O.dehydrated;if(AE(p),k)if(p.flags&256)p.flags&=-257,p=zI(f,p,_);else if(p.memoizedState!==null)p.child=f.child,p.flags|=128,p=null;else throw Error(r(558));else if(zr||ph(f,p,_,!1),k=(_&f.childLanes)!==0,zr||k){if(S=Kn,S!==null&&(Y=bi(S,_),Y!==0&&Y!==O.retryLane))throw O.retryLane=Y,wd(f,Y),js(S,f,Y),qE;Ev(),p=zI(f,p,_)}else f=O.treeContext,rr=Na(Y.nextSibling),Ui=p,yn=!0,zc=null,Ta=!1,f!==null&&Aj(p,f),p=pv(p,S),p.flags|=4096;return p}return f=_l(f.child,{mode:S.mode,children:S.children}),f.ref=p.ref,p.child=f,f.return=p,f}function mv(f,p){var _=p.ref;if(_===null)f!==null&&f.ref!==null&&(p.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(f===null||f.ref!==_)&&(p.flags|=4194816)}}function XE(f,p,_,S,k){return Td(p),_=CE(f,p,_,S,void 0,k),S=NE(),f!==null&&!zr?(kE(f,p,k),Tl(f,p,k)):(yn&&S&&cE(p),p.flags|=1,zi(f,p,_,k),p.child)}function BI(f,p,_,S,k,O){return Td(p),p.updateQueue=null,_=Vj(p,S,_,k),$j(f),S=NE(),f!==null&&!zr?(kE(f,p,O),Tl(f,p,O)):(yn&&S&&cE(p),p.flags|=1,zi(f,p,_,O),p.child)}function HI(f,p,_,S,k){if(Td(p),p.stateNode===null){var O=uh,Y=_.contextType;typeof Y=="object"&&Y!==null&&(O=Fi(Y)),O=new _(S,O),p.memoizedState=O.state!==null&&O.state!==void 0?O.state:null,O.updater=GE,p.stateNode=O,O._reactInternals=p,O=p.stateNode,O.props=S,O.state=p.memoizedState,O.refs={},yE(p),Y=_.contextType,O.context=typeof Y=="object"&&Y!==null?Fi(Y):uh,O.state=p.memoizedState,Y=_.getDerivedStateFromProps,typeof Y=="function"&&(VE(p,_,Y,S),O.state=p.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof O.getSnapshotBeforeUpdate=="function"||typeof O.UNSAFE_componentWillMount!="function"&&typeof O.componentWillMount!="function"||(Y=O.state,typeof O.componentWillMount=="function"&&O.componentWillMount(),typeof O.UNSAFE_componentWillMount=="function"&&O.UNSAFE_componentWillMount(),Y!==O.state&&GE.enqueueReplaceState(O,O.state,null),bg(p,S,O,k),xg(),O.state=p.memoizedState),typeof O.componentDidMount=="function"&&(p.flags|=4194308),S=!0}else if(f===null){O=p.stateNode;var oe=p.memoizedProps,we=Dd(_,oe);O.props=we;var Ve=O.context,st=_.contextType;Y=uh,typeof st=="object"&&st!==null&&(Y=Fi(st));var dt=_.getDerivedStateFromProps;st=typeof dt=="function"||typeof O.getSnapshotBeforeUpdate=="function",oe=p.pendingProps!==oe,st||typeof O.UNSAFE_componentWillReceiveProps!="function"&&typeof O.componentWillReceiveProps!="function"||(oe||Ve!==Y)&&CI(p,O,S,Y),$c=!1;var qe=p.memoizedState;O.state=qe,bg(p,S,O,k),xg(),Ve=p.memoizedState,oe||qe!==Ve||$c?(typeof dt=="function"&&(VE(p,_,dt,S),Ve=p.memoizedState),(we=$c||TI(p,_,we,S,qe,Ve,Y))?(st||typeof O.UNSAFE_componentWillMount!="function"&&typeof O.componentWillMount!="function"||(typeof O.componentWillMount=="function"&&O.componentWillMount(),typeof O.UNSAFE_componentWillMount=="function"&&O.UNSAFE_componentWillMount()),typeof O.componentDidMount=="function"&&(p.flags|=4194308)):(typeof O.componentDidMount=="function"&&(p.flags|=4194308),p.memoizedProps=S,p.memoizedState=Ve),O.props=S,O.state=Ve,O.context=Y,S=we):(typeof O.componentDidMount=="function"&&(p.flags|=4194308),S=!1)}else{O=p.stateNode,_E(f,p),Y=p.memoizedProps,st=Dd(_,Y),O.props=st,dt=p.pendingProps,qe=O.context,Ve=_.contextType,we=uh,typeof Ve=="object"&&Ve!==null&&(we=Fi(Ve)),oe=_.getDerivedStateFromProps,(Ve=typeof oe=="function"||typeof O.getSnapshotBeforeUpdate=="function")||typeof O.UNSAFE_componentWillReceiveProps!="function"&&typeof O.componentWillReceiveProps!="function"||(Y!==dt||qe!==we)&&CI(p,O,S,we),$c=!1,qe=p.memoizedState,O.state=qe,bg(p,S,O,k),xg();var rt=p.memoizedState;Y!==dt||qe!==rt||$c||f!==null&&f.dependencies!==null&&Yb(f.dependencies)?(typeof oe=="function"&&(VE(p,_,oe,S),rt=p.memoizedState),(st=$c||TI(p,_,st,S,qe,rt,we)||f!==null&&f.dependencies!==null&&Yb(f.dependencies))?(Ve||typeof O.UNSAFE_componentWillUpdate!="function"&&typeof O.componentWillUpdate!="function"||(typeof O.componentWillUpdate=="function"&&O.componentWillUpdate(S,rt,we),typeof O.UNSAFE_componentWillUpdate=="function"&&O.UNSAFE_componentWillUpdate(S,rt,we)),typeof O.componentDidUpdate=="function"&&(p.flags|=4),typeof O.getSnapshotBeforeUpdate=="function"&&(p.flags|=1024)):(typeof O.componentDidUpdate!="function"||Y===f.memoizedProps&&qe===f.memoizedState||(p.flags|=4),typeof O.getSnapshotBeforeUpdate!="function"||Y===f.memoizedProps&&qe===f.memoizedState||(p.flags|=1024),p.memoizedProps=S,p.memoizedState=rt),O.props=S,O.state=rt,O.context=we,S=st):(typeof O.componentDidUpdate!="function"||Y===f.memoizedProps&&qe===f.memoizedState||(p.flags|=4),typeof O.getSnapshotBeforeUpdate!="function"||Y===f.memoizedProps&&qe===f.memoizedState||(p.flags|=1024),S=!1)}return O=S,mv(f,p),S=(p.flags&128)!==0,O||S?(O=p.stateNode,_=S&&typeof _.getDerivedStateFromError!="function"?null:O.render(),p.flags|=1,f!==null&&S?(p.child=Rd(p,f.child,null,k),p.child=Rd(p,null,_,k)):zi(f,p,_,k),p.memoizedState=O.state,f=p.child):f=Tl(f,p,k),f}function $I(f,p,_,S){return Ed(),p.flags|=256,zi(f,p,_,S),p.child}var KE={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function YE(f){return{baseLanes:f,cachePool:Pj()}}function ZE(f,p,_){return f=f!==null?f.childLanes&~_:0,p&&(f|=ia),f}function VI(f,p,_){var S=p.pendingProps,k=!1,O=(p.flags&128)!==0,Y;if((Y=O)||(Y=f!==null&&f.memoizedState===null?!1:(Rr.current&2)!==0),Y&&(k=!0,p.flags&=-129),Y=(p.flags&32)!==0,p.flags&=-33,f===null){if(yn){if(k?Wc(p):qc(),(f=rr)?(f=Q4(f,Ta),f=f!==null&&f.data!=="&"?f:null,f!==null&&(p.memoizedState={dehydrated:f,treeContext:Fc!==null?{id:zo,overflow:Bo}:null,retryLane:536870912,hydrationErrors:null},_=wj(f),_.return=p,p.child=_,Ui=p,rr=null)):f=null,f===null)throw Bc(p);return D2(f)?p.lanes=32:p.lanes=536870912,null}var oe=S.children;return S=S.fallback,k?(qc(),k=p.mode,oe=gv({mode:"hidden",children:oe},k),S=Md(S,k,_,null),oe.return=p,S.return=p,oe.sibling=S,p.child=oe,S=p.child,S.memoizedState=YE(_),S.childLanes=ZE(f,Y,_),p.memoizedState=KE,wg(null,S)):(Wc(p),QE(p,oe))}var we=f.memoizedState;if(we!==null&&(oe=we.dehydrated,oe!==null)){if(O)p.flags&256?(Wc(p),p.flags&=-257,p=JE(f,p,_)):p.memoizedState!==null?(qc(),p.child=f.child,p.flags|=128,p=null):(qc(),oe=S.fallback,k=p.mode,S=gv({mode:"visible",children:S.children},k),oe=Md(oe,k,_,null),oe.flags|=2,S.return=p,oe.return=p,S.sibling=oe,p.child=S,Rd(p,f.child,null,_),S=p.child,S.memoizedState=YE(_),S.childLanes=ZE(f,Y,_),p.memoizedState=KE,p=wg(null,S));else if(Wc(p),D2(oe)){if(Y=oe.nextSibling&&oe.nextSibling.dataset,Y)var Ve=Y.dgst;Y=Ve,S=Error(r(419)),S.stack="",S.digest=Y,dg({value:S,source:null,stack:null}),p=JE(f,p,_)}else if(zr||ph(f,p,_,!1),Y=(_&f.childLanes)!==0,zr||Y){if(Y=Kn,Y!==null&&(S=bi(Y,_),S!==0&&S!==we.retryLane))throw we.retryLane=S,wd(f,S),js(Y,f,S),qE;P2(oe)||Ev(),p=JE(f,p,_)}else P2(oe)?(p.flags|=192,p.child=f.child,p=null):(f=we.treeContext,rr=Na(oe.nextSibling),Ui=p,yn=!0,zc=null,Ta=!1,f!==null&&Aj(p,f),p=QE(p,S.children),p.flags|=4096);return p}return k?(qc(),oe=S.fallback,k=p.mode,we=f.child,Ve=we.sibling,S=_l(we,{mode:"hidden",children:S.children}),S.subtreeFlags=we.subtreeFlags&65011712,Ve!==null?oe=_l(Ve,oe):(oe=Md(oe,k,_,null),oe.flags|=2),oe.return=p,S.return=p,S.sibling=oe,p.child=S,wg(null,S),S=p.child,oe=f.child.memoizedState,oe===null?oe=YE(_):(k=oe.cachePool,k!==null?(we=Ur._currentValue,k=k.parent!==we?{parent:we,pool:we}:k):k=Pj(),oe={baseLanes:oe.baseLanes|_,cachePool:k}),S.memoizedState=oe,S.childLanes=ZE(f,Y,_),p.memoizedState=KE,wg(f.child,S)):(Wc(p),_=f.child,f=_.sibling,_=_l(_,{mode:"visible",children:S.children}),_.return=p,_.sibling=null,f!==null&&(Y=p.deletions,Y===null?(p.deletions=[f],p.flags|=16):Y.push(f)),p.child=_,p.memoizedState=null,_)}function QE(f,p){return p=gv({mode:"visible",children:p},f.mode),p.return=f,f.child=p}function gv(f,p){return f=ea(22,f,null,p),f.lanes=0,f}function JE(f,p,_){return Rd(p,f.child,null,_),f=QE(p,p.pendingProps.children),f.flags|=2,p.memoizedState=null,f}function GI(f,p,_){f.lanes|=p;var S=f.alternate;S!==null&&(S.lanes|=p),pE(f.return,p,_)}function e2(f,p,_,S,k,O){var Y=f.memoizedState;Y===null?f.memoizedState={isBackwards:p,rendering:null,renderingStartTime:0,last:S,tail:_,tailMode:k,treeForkCount:O}:(Y.isBackwards=p,Y.rendering=null,Y.renderingStartTime=0,Y.last=S,Y.tail=_,Y.tailMode=k,Y.treeForkCount=O)}function WI(f,p,_){var S=p.pendingProps,k=S.revealOrder,O=S.tail;S=S.children;var Y=Rr.current,oe=(Y&2)!==0;if(oe?(Y=Y&1|2,p.flags|=128):Y&=1,te(Rr,Y),zi(f,p,S,_),S=yn?ug:0,!oe&&f!==null&&(f.flags&128)!==0)e:for(f=p.child;f!==null;){if(f.tag===13)f.memoizedState!==null&&GI(f,_,p);else if(f.tag===19)GI(f,_,p);else if(f.child!==null){f.child.return=f,f=f.child;continue}if(f===p)break e;for(;f.sibling===null;){if(f.return===null||f.return===p)break e;f=f.return}f.sibling.return=f.return,f=f.sibling}switch(k){case"forwards":for(_=p.child,k=null;_!==null;)f=_.alternate,f!==null&&iv(f)===null&&(k=_),_=_.sibling;_=k,_===null?(k=p.child,p.child=null):(k=_.sibling,_.sibling=null),e2(p,!1,k,_,O,S);break;case"backwards":case"unstable_legacy-backwards":for(_=null,k=p.child,p.child=null;k!==null;){if(f=k.alternate,f!==null&&iv(f)===null){p.child=k;break}f=k.sibling,k.sibling=_,_=k,k=f}e2(p,!0,_,null,O,S);break;case"together":e2(p,!1,null,null,void 0,S);break;default:p.memoizedState=null}return p.child}function Tl(f,p,_){if(f!==null&&(p.dependencies=f.dependencies),Yc|=p.lanes,(_&p.childLanes)===0)if(f!==null){if(ph(f,p,_,!1),(_&p.childLanes)===0)return null}else return null;if(f!==null&&p.child!==f.child)throw Error(r(153));if(p.child!==null){for(f=p.child,_=_l(f,f.pendingProps),p.child=_,_.return=p;f.sibling!==null;)f=f.sibling,_=_.sibling=_l(f,f.pendingProps),_.return=p;_.sibling=null}return p.child}function t2(f,p){return(f.lanes&p)!==0?!0:(f=f.dependencies,!!(f!==null&&Yb(f)))}function Lte(f,p,_){switch(p.tag){case 3:Me(p,p.stateNode.containerInfo),Hc(p,Ur,f.memoizedState.cache),Ed();break;case 27:case 5:Se(p);break;case 4:Me(p,p.stateNode.containerInfo);break;case 10:Hc(p,p.type,p.memoizedProps.value);break;case 31:if(p.memoizedState!==null)return p.flags|=128,AE(p),null;break;case 13:var S=p.memoizedState;if(S!==null)return S.dehydrated!==null?(Wc(p),p.flags|=128,null):(_&p.child.childLanes)!==0?VI(f,p,_):(Wc(p),f=Tl(f,p,_),f!==null?f.sibling:null);Wc(p);break;case 19:var k=(f.flags&128)!==0;if(S=(_&p.childLanes)!==0,S||(ph(f,p,_,!1),S=(_&p.childLanes)!==0),k){if(S)return WI(f,p,_);p.flags|=128}if(k=p.memoizedState,k!==null&&(k.rendering=null,k.tail=null,k.lastEffect=null),te(Rr,Rr.current),S)break;return null;case 22:return p.lanes=0,UI(f,p,_,p.pendingProps);case 24:Hc(p,Ur,f.memoizedState.cache)}return Tl(f,p,_)}function qI(f,p,_){if(f!==null)if(f.memoizedProps!==p.pendingProps)zr=!0;else{if(!t2(f,_)&&(p.flags&128)===0)return zr=!1,Lte(f,p,_);zr=(f.flags&131072)!==0}else zr=!1,yn&&(p.flags&1048576)!==0&&Ej(p,ug,p.index);switch(p.lanes=0,p.tag){case 16:e:{var S=p.pendingProps;if(f=Nd(p.elementType),p.type=f,typeof f=="function")aE(f)?(S=Dd(f,S),p.tag=1,p=HI(null,p,f,S,_)):(p.tag=0,p=XE(null,p,f,S,_));else{if(f!=null){var k=f.$$typeof;if(k===A){p.tag=11,p=LI(null,p,f,S,_);break e}else if(k===C){p.tag=14,p=jI(null,p,f,S,_);break e}}throw p=P(f)||f,Error(r(306,p,""))}}return p;case 0:return XE(f,p,p.type,p.pendingProps,_);case 1:return S=p.type,k=Dd(S,p.pendingProps),HI(f,p,S,k,_);case 3:e:{if(Me(p,p.stateNode.containerInfo),f===null)throw Error(r(387));S=p.pendingProps;var O=p.memoizedState;k=O.element,_E(f,p),bg(p,S,null,_);var Y=p.memoizedState;if(S=Y.cache,Hc(p,Ur,S),S!==O.cache&&mE(p,[Ur],_,!0),xg(),S=Y.element,O.isDehydrated)if(O={element:S,isDehydrated:!1,cache:Y.cache},p.updateQueue.baseState=O,p.memoizedState=O,p.flags&256){p=$I(f,p,S,_);break e}else if(S!==k){k=Ma(Error(r(424)),p),dg(k),p=$I(f,p,S,_);break e}else{switch(f=p.stateNode.containerInfo,f.nodeType){case 9:f=f.body;break;default:f=f.nodeName==="HTML"?f.ownerDocument.body:f}for(rr=Na(f.firstChild),Ui=p,yn=!0,zc=null,Ta=!0,_=Uj(p,null,S,_),p.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(Ed(),S===k){p=Tl(f,p,_);break e}zi(f,p,S,_)}p=p.child}return p;case 26:return mv(f,p),f===null?(_=iU(p.type,null,p.pendingProps,null))?p.memoizedState=_:yn||(_=p.type,f=p.pendingProps,S=Pv(ue.current).createElement(_),S[gr]=p,S[Nr]=f,Bi(S,_,f),Pe(S),p.stateNode=S):p.memoizedState=iU(p.type,f.memoizedProps,p.pendingProps,f.memoizedState),null;case 27:return Se(p),f===null&&yn&&(S=p.stateNode=tU(p.type,p.pendingProps,ue.current),Ui=p,Ta=!0,k=rr,tu(p.type)?(O2=k,rr=Na(S.firstChild)):rr=k),zi(f,p,p.pendingProps.children,_),mv(f,p),f===null&&(p.flags|=4194304),p.child;case 5:return f===null&&yn&&((k=S=rr)&&(S=dne(S,p.type,p.pendingProps,Ta),S!==null?(p.stateNode=S,Ui=p,rr=Na(S.firstChild),Ta=!1,k=!0):k=!1),k||Bc(p)),Se(p),k=p.type,O=p.pendingProps,Y=f!==null?f.memoizedProps:null,S=O.children,N2(k,O)?S=null:Y!==null&&N2(k,Y)&&(p.flags|=32),p.memoizedState!==null&&(k=CE(f,p,Ate,null,null,_),Ug._currentValue=k),mv(f,p),zi(f,p,S,_),p.child;case 6:return f===null&&yn&&((f=_=rr)&&(_=fne(_,p.pendingProps,Ta),_!==null?(p.stateNode=_,Ui=p,rr=null,f=!0):f=!1),f||Bc(p)),null;case 13:return VI(f,p,_);case 4:return Me(p,p.stateNode.containerInfo),S=p.pendingProps,f===null?p.child=Rd(p,null,S,_):zi(f,p,S,_),p.child;case 11:return LI(f,p,p.type,p.pendingProps,_);case 7:return zi(f,p,p.pendingProps,_),p.child;case 8:return zi(f,p,p.pendingProps.children,_),p.child;case 12:return zi(f,p,p.pendingProps.children,_),p.child;case 10:return S=p.pendingProps,Hc(p,p.type,S.value),zi(f,p,S.children,_),p.child;case 9:return k=p.type._context,S=p.pendingProps.children,Td(p),k=Fi(k),S=S(k),p.flags|=1,zi(f,p,S,_),p.child;case 14:return jI(f,p,p.type,p.pendingProps,_);case 15:return II(f,p,p.type,p.pendingProps,_);case 19:return WI(f,p,_);case 31:return Ote(f,p,_);case 22:return UI(f,p,_,p.pendingProps);case 24:return Td(p),S=Fi(Ur),f===null?(k=bE(),k===null&&(k=Kn,O=gE(),k.pooledCache=O,O.refCount++,O!==null&&(k.pooledCacheLanes|=_),k=O),p.memoizedState={parent:S,cache:k},yE(p),Hc(p,Ur,k)):((f.lanes&_)!==0&&(_E(f,p),bg(p,null,null,_),xg()),k=f.memoizedState,O=p.memoizedState,k.parent!==S?(k={parent:S,cache:S},p.memoizedState=k,p.lanes===0&&(p.memoizedState=p.updateQueue.baseState=k),Hc(p,Ur,S)):(S=O.cache,Hc(p,Ur,S),S!==k.cache&&mE(p,[Ur],_,!0))),zi(f,p,p.pendingProps.children,_),p.child;case 29:throw p.pendingProps}throw Error(r(156,p.tag))}function Cl(f){f.flags|=4}function n2(f,p,_,S,k){if((p=(f.mode&32)!==0)&&(p=!1),p){if(f.flags|=16777216,(k&335544128)===k)if(f.stateNode.complete)f.flags|=8192;else if(y4())f.flags|=8192;else throw kd=ev,vE}else f.flags&=-16777217}function XI(f,p){if(p.type!=="stylesheet"||(p.state.loading&4)!==0)f.flags&=-16777217;else if(f.flags|=16777216,!cU(p))if(y4())f.flags|=8192;else throw kd=ev,vE}function xv(f,p){p!==null&&(f.flags|=4),f.flags&16384&&(p=f.tag!==22?yt():536870912,f.lanes|=p,Ah|=p)}function Mg(f,p){if(!yn)switch(f.tailMode){case"hidden":p=f.tail;for(var _=null;p!==null;)p.alternate!==null&&(_=p),p=p.sibling;_===null?f.tail=null:_.sibling=null;break;case"collapsed":_=f.tail;for(var S=null;_!==null;)_.alternate!==null&&(S=_),_=_.sibling;S===null?p||f.tail===null?f.tail=null:f.tail.sibling=null:S.sibling=null}}function ir(f){var p=f.alternate!==null&&f.alternate.child===f.child,_=0,S=0;if(p)for(var k=f.child;k!==null;)_|=k.lanes|k.childLanes,S|=k.subtreeFlags&65011712,S|=k.flags&65011712,k.return=f,k=k.sibling;else for(k=f.child;k!==null;)_|=k.lanes|k.childLanes,S|=k.subtreeFlags,S|=k.flags,k.return=f,k=k.sibling;return f.subtreeFlags|=S,f.childLanes=_,p}function jte(f,p,_){var S=p.pendingProps;switch(uE(p),p.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return ir(p),null;case 1:return ir(p),null;case 3:return _=p.stateNode,S=null,f!==null&&(S=f.memoizedState.cache),p.memoizedState.cache!==S&&(p.flags|=2048),Ml(Ur),he(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(f===null||f.child===null)&&(hh(p)?Cl(p):f===null||f.memoizedState.isDehydrated&&(p.flags&256)===0||(p.flags|=1024,fE())),ir(p),null;case 26:var k=p.type,O=p.memoizedState;return f===null?(Cl(p),O!==null?(ir(p),XI(p,O)):(ir(p),n2(p,k,null,S,_))):O?O!==f.memoizedState?(Cl(p),ir(p),XI(p,O)):(ir(p),p.flags&=-16777217):(f=f.memoizedProps,f!==S&&Cl(p),ir(p),n2(p,k,f,S,_)),null;case 27:if(Re(p),_=ue.current,k=p.type,f!==null&&p.stateNode!=null)f.memoizedProps!==S&&Cl(p);else{if(!S){if(p.stateNode===null)throw Error(r(166));return ir(p),null}f=ne.current,hh(p)?Tj(p):(f=tU(k,S,_),p.stateNode=f,Cl(p))}return ir(p),null;case 5:if(Re(p),k=p.type,f!==null&&p.stateNode!=null)f.memoizedProps!==S&&Cl(p);else{if(!S){if(p.stateNode===null)throw Error(r(166));return ir(p),null}if(O=ne.current,hh(p))Tj(p);else{var Y=Pv(ue.current);switch(O){case 1:O=Y.createElementNS("http://www.w3.org/2000/svg",k);break;case 2:O=Y.createElementNS("http://www.w3.org/1998/Math/MathML",k);break;default:switch(k){case"svg":O=Y.createElementNS("http://www.w3.org/2000/svg",k);break;case"math":O=Y.createElementNS("http://www.w3.org/1998/Math/MathML",k);break;case"script":O=Y.createElement("div"),O.innerHTML="
-
+
+
diff --git a/static/manifest.json b/static/manifest.json
index 712db26dd..48eb8eef3 100644
--- a/static/manifest.json
+++ b/static/manifest.json
@@ -60,6 +60,13 @@
"description": "View print queue",
"url": "/queue",
"icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Projects",
+ "short_name": "Projects",
+ "description": "View print projects",
+ "url": "/projects",
+ "icons": [{ "src": "/img/android-chrome-192x192.png", "sizes": "192x192" }]
}
]
}
|