diff --git a/CHANGELOG.md b/CHANGELOG.md index 50da35f07..66882caad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to Bambuddy will be documented in this file. ## [Unreleased] ### Added +- **Add to Queue from File Manager** - Queue sliced files directly from File Manager: + - New "Add to Queue" toolbar button appears when sliced files are selected + - Context menu and list view button options for individual files + - Supports multiple file selection for batch queueing + - Only accepts sliced files (.gcode or .gcode.3mf) + - Creates archive and queue item in one action +- **Print Queue plate selection and options** - Full print configuration in queue edit modal: + - Plate selection grid with thumbnails for multi-plate 3MF files + - Print options section (bed levelling, flow calibration, vibration calibration, layer inspect, timelapse, use AMS) + - Options saved with queue item and used when print starts - **Multi-plate 3MF plate selection** - When reprinting multi-plate 3MF files (exported with "All sliced file"), users can now select which plate to print: - Plate selection grid with thumbnails, names, and print times - Filament requirements filtered to show only selected plate's filaments diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py new file mode 100644 index 000000000..44321d747 --- /dev/null +++ b/backend/app/api/routes/library.py @@ -0,0 +1,1168 @@ +"""API routes for File Manager (Library) functionality.""" + +import base64 +import hashlib +import logging +import os +import re +import shutil +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import FileResponse as FastAPIFileResponse +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.config import settings as app_settings +from backend.app.core.database import get_db +from backend.app.models.archive import PrintArchive +from backend.app.models.library import LibraryFile, LibraryFolder +from backend.app.models.print_queue import PrintQueueItem +from backend.app.models.project import Project +from backend.app.schemas.library import ( + AddToQueueError, + AddToQueueRequest, + AddToQueueResponse, + AddToQueueResult, + BulkDeleteRequest, + BulkDeleteResponse, + FileDuplicate, + FileListResponse, + FileMoveRequest, + FileResponse as FileResponseSchema, + FileUpdate, + FileUploadResponse, + FolderCreate, + FolderResponse, + FolderTreeItem, + FolderUpdate, +) +from backend.app.services.archive import ArchiveService, ThreeMFParser + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/library", tags=["library"]) + + +def get_library_dir() -> Path: + """Get the library storage directory.""" + base_dir = Path(app_settings.archive_dir) + library_dir = base_dir / "library" + library_dir.mkdir(parents=True, exist_ok=True) + return library_dir + + +def get_library_files_dir() -> Path: + """Get the directory for library files.""" + files_dir = get_library_dir() / "files" + files_dir.mkdir(parents=True, exist_ok=True) + return files_dir + + +def get_library_thumbnails_dir() -> Path: + """Get the directory for library thumbnails.""" + thumbnails_dir = get_library_dir() / "thumbnails" + thumbnails_dir.mkdir(parents=True, exist_ok=True) + return thumbnails_dir + + +def calculate_file_hash(file_path: Path) -> str: + """Calculate SHA256 hash of a file.""" + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + return sha256_hash.hexdigest() + + +def extract_gcode_thumbnail(file_path: Path) -> bytes | None: + """Extract embedded thumbnail from gcode file. + + Supports PrusaSlicer/BambuStudio format: + ; thumbnail begin WxH SIZE + ; base64data... + ; thumbnail end + """ + try: + thumbnail_data = None + in_thumbnail = False + thumbnail_lines = [] + best_size = 0 + + with open(file_path, errors="ignore") as f: + # Only read first 50KB for performance (thumbnails are at the start) + content = f.read(50000) + + for line in content.split("\n"): + line = line.strip() + + # Check for thumbnail start + if line.startswith("; thumbnail begin"): + in_thumbnail = True + thumbnail_lines = [] + # Parse dimensions: "; thumbnail begin 300x300 12345" + match = re.search(r"(\d+)x(\d+)", line) + if match: + width = int(match.group(1)) + # Prefer larger thumbnails (up to 300px) + if width > best_size and width <= 300: + best_size = width + continue + + # Check for thumbnail end + if line.startswith("; thumbnail end"): + if in_thumbnail and thumbnail_lines: + try: + # Decode the base64 data + b64_data = "".join(thumbnail_lines) + decoded = base64.b64decode(b64_data) + # Only keep if this is the best size or first valid thumbnail + if thumbnail_data is None or best_size > 0: + thumbnail_data = decoded + except Exception: + pass + in_thumbnail = False + thumbnail_lines = [] + continue + + # Collect thumbnail data + if in_thumbnail and line.startswith(";"): + # Remove the leading "; " or ";" + data_line = line[1:].strip() + if data_line: + thumbnail_lines.append(data_line) + + return thumbnail_data + except Exception as e: + logger.warning(f"Failed to extract gcode thumbnail: {e}") + return None + + +def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int = 256) -> str | None: + """Create a thumbnail from an image file. + + For small images, copies directly. For larger images, resizes. + Returns the thumbnail path or None on failure. + """ + try: + from PIL import Image + + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + + with Image.open(file_path) as img: + # Convert to RGB if necessary (for PNG with transparency, etc.) + if img.mode in ("RGBA", "LA", "P"): + # Create white background for transparency + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None) + img = background + elif img.mode != "RGB": + img = img.convert("RGB") + + # Resize if larger than max_size + if img.width > max_size or img.height > max_size: + img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) + + img.save(thumb_path, "PNG", optimize=True) + + return str(thumb_path) + except ImportError: + # PIL not installed, just copy the file if it's small enough + logger.warning("PIL not installed, copying image as thumbnail") + try: + file_size = file_path.stat().st_size + if file_size < 500000: # Less than 500KB + thumb_filename = f"{uuid.uuid4().hex}{file_path.suffix}" + thumb_path = thumbnails_dir / thumb_filename + shutil.copy2(file_path, thumb_path) + return str(thumb_path) + except Exception: + pass + return None + except Exception as e: + logger.warning(f"Failed to create image thumbnail: {e}") + return None + + +# Supported image extensions for thumbnails +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"} + + +# ============ Folder Endpoints ============ + + +@router.get("/folders", response_model=list[FolderTreeItem]) +@router.get("/folders/", response_model=list[FolderTreeItem]) +async def list_folders(db: AsyncSession = Depends(get_db)): + """Get all folders as a tree structure.""" + # Get all folders with project and archive joins + result = await db.execute( + select(LibraryFolder, Project.name, PrintArchive.print_name) + .outerjoin(Project, LibraryFolder.project_id == Project.id) + .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id) + .order_by(LibraryFolder.name) + ) + rows = result.all() + + # Get file counts per folder + file_counts_result = await db.execute( + select(LibraryFile.folder_id, func.count(LibraryFile.id)) + .where(LibraryFile.folder_id.isnot(None)) + .group_by(LibraryFile.folder_id) + ) + file_counts = dict(file_counts_result.all()) + + # Build tree structure + folder_map = {} + root_folders = [] + + for folder, project_name, archive_name in rows: + folder_item = FolderTreeItem( + id=folder.id, + name=folder.name, + parent_id=folder.parent_id, + project_id=folder.project_id, + archive_id=folder.archive_id, + project_name=project_name, + archive_name=archive_name, + file_count=file_counts.get(folder.id, 0), + children=[], + ) + folder_map[folder.id] = folder_item + + # Link children to parents + for folder, _, _ in rows: + folder_item = folder_map[folder.id] + if folder.parent_id is None: + root_folders.append(folder_item) + elif folder.parent_id in folder_map: + folder_map[folder.parent_id].children.append(folder_item) + + return root_folders + + +@router.get("/folders/by-project/{project_id}", response_model=list[FolderResponse]) +async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get_db)): + """Get all folders linked to a specific project.""" + result = await db.execute( + select(LibraryFolder, Project.name) + .outerjoin(Project, LibraryFolder.project_id == Project.id) + .where(LibraryFolder.project_id == project_id) + .order_by(LibraryFolder.name) + ) + rows = result.all() + + folders = [] + for folder, project_name in rows: + # Get file count + file_count_result = await db.execute( + select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id) + ) + file_count = file_count_result.scalar() or 0 + + folders.append( + FolderResponse( + id=folder.id, + name=folder.name, + parent_id=folder.parent_id, + project_id=folder.project_id, + archive_id=folder.archive_id, + project_name=project_name, + archive_name=None, + file_count=file_count, + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + ) + + return folders + + +@router.get("/folders/by-archive/{archive_id}", response_model=list[FolderResponse]) +async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get_db)): + """Get all folders linked to a specific archive.""" + result = await db.execute( + select(LibraryFolder, PrintArchive.print_name) + .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id) + .where(LibraryFolder.archive_id == archive_id) + .order_by(LibraryFolder.name) + ) + rows = result.all() + + folders = [] + for folder, archive_name in rows: + # Get file count + file_count_result = await db.execute( + select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id) + ) + file_count = file_count_result.scalar() or 0 + + folders.append( + FolderResponse( + id=folder.id, + name=folder.name, + parent_id=folder.parent_id, + project_id=folder.project_id, + archive_id=folder.archive_id, + project_name=None, + archive_name=archive_name, + file_count=file_count, + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + ) + + return folders + + +@router.post("/folders", response_model=FolderResponse) +@router.post("/folders/", response_model=FolderResponse) +async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)): + """Create a new folder.""" + # Verify parent exists if specified + if data.parent_id is not None: + parent_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.parent_id)) + if not parent_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Parent folder not found") + + # Verify project exists if specified + project_name = None + if data.project_id is not None: + project_result = await db.execute(select(Project).where(Project.id == data.project_id)) + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + project_name = project.name + + # Verify archive exists if specified + archive_name = None + if data.archive_id is not None: + archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id)) + archive = archive_result.scalar_one_or_none() + if not archive: + raise HTTPException(status_code=404, detail="Archive not found") + archive_name = archive.print_name + + folder = LibraryFolder( + name=data.name, + parent_id=data.parent_id, + project_id=data.project_id, + archive_id=data.archive_id, + ) + db.add(folder) + await db.flush() + await db.refresh(folder) + + return FolderResponse( + id=folder.id, + name=folder.name, + parent_id=folder.parent_id, + project_id=folder.project_id, + archive_id=folder.archive_id, + project_name=project_name, + archive_name=archive_name, + file_count=0, + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +@router.get("/folders/{folder_id}", response_model=FolderResponse) +async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)): + """Get a folder by ID.""" + result = await db.execute( + select(LibraryFolder, Project.name, PrintArchive.print_name) + .outerjoin(Project, LibraryFolder.project_id == Project.id) + .outerjoin(PrintArchive, LibraryFolder.archive_id == PrintArchive.id) + .where(LibraryFolder.id == folder_id) + ) + row = result.one_or_none() + + if not row: + raise HTTPException(status_code=404, detail="Folder not found") + + folder, project_name, archive_name = row + + # Get file count + file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id)) + file_count = file_count_result.scalar() or 0 + + return FolderResponse( + id=folder.id, + name=folder.name, + parent_id=folder.parent_id, + project_id=folder.project_id, + archive_id=folder.archive_id, + project_name=project_name, + archive_name=archive_name, + file_count=file_count, + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +@router.put("/folders/{folder_id}", response_model=FolderResponse) +async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = Depends(get_db)): + """Update a folder.""" + result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id)) + folder = result.scalar_one_or_none() + + if not folder: + raise HTTPException(status_code=404, detail="Folder not found") + + if data.name is not None: + folder.name = data.name + + if data.parent_id is not None: + # Prevent circular reference + if data.parent_id == folder_id: + raise HTTPException(status_code=400, detail="Folder cannot be its own parent") + + # Check for circular reference in ancestors + if data.parent_id != 0: # 0 means move to root + current_id = data.parent_id + while current_id is not None: + if current_id == folder_id: + raise HTTPException(status_code=400, detail="Cannot move folder into its own subtree") + parent_result = await db.execute(select(LibraryFolder.parent_id).where(LibraryFolder.id == current_id)) + current_id = parent_result.scalar() + + folder.parent_id = data.parent_id + else: + folder.parent_id = None + + # Update project_id (0 to unlink) + if data.project_id is not None: + if data.project_id == 0: + folder.project_id = None + else: + # Verify project exists + project_result = await db.execute(select(Project).where(Project.id == data.project_id)) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + folder.project_id = data.project_id + + # Update archive_id (0 to unlink) + if data.archive_id is not None: + if data.archive_id == 0: + folder.archive_id = None + else: + # Verify archive exists + archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id)) + if not archive_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Archive not found") + folder.archive_id = data.archive_id + + await db.flush() + await db.refresh(folder) + + # Get file count and names + file_count_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id)) + file_count = file_count_result.scalar() or 0 + + # Get project and archive names + project_name = None + archive_name = None + if folder.project_id: + project_result = await db.execute(select(Project.name).where(Project.id == folder.project_id)) + project_name = project_result.scalar() + if folder.archive_id: + archive_result = await db.execute(select(PrintArchive.print_name).where(PrintArchive.id == folder.archive_id)) + archive_name = archive_result.scalar() + + return FolderResponse( + id=folder.id, + name=folder.name, + parent_id=folder.parent_id, + project_id=folder.project_id, + archive_id=folder.archive_id, + project_name=project_name, + archive_name=archive_name, + file_count=file_count, + created_at=folder.created_at, + updated_at=folder.updated_at, + ) + + +@router.delete("/folders/{folder_id}") +async def delete_folder(folder_id: int, db: AsyncSession = Depends(get_db)): + """Delete a folder and all its contents (cascade).""" + result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id)) + folder = result.scalar_one_or_none() + + if not folder: + raise HTTPException(status_code=404, detail="Folder not found") + + # Get all files in this folder and subfolders to delete from disk + async def get_all_file_ids(fid: int) -> list[int]: + """Recursively get all file IDs in a folder tree.""" + file_ids = [] + + # Get files in this folder + files_result = await db.execute( + select(LibraryFile.id, LibraryFile.file_path, LibraryFile.thumbnail_path).where( + LibraryFile.folder_id == fid + ) + ) + for file_id, file_path, thumb_path in files_result.all(): + file_ids.append(file_id) + # Delete actual files + try: + if file_path and os.path.exists(file_path): + os.remove(file_path) + if thumb_path and os.path.exists(thumb_path): + os.remove(thumb_path) + except Exception as e: + logger.warning(f"Failed to delete file: {e}") + + # Get child folders and recurse + children_result = await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == fid)) + for (child_id,) in children_result.all(): + file_ids.extend(await get_all_file_ids(child_id)) + + return file_ids + + await get_all_file_ids(folder_id) + + # Delete folder (cascade will handle files and subfolders) + await db.delete(folder) + + return {"status": "success", "message": "Folder deleted"} + + +# ============ File Endpoints ============ + + +@router.get("/files", response_model=list[FileListResponse]) +@router.get("/files/", response_model=list[FileListResponse]) +async def list_files( + folder_id: int | None = None, + include_root: bool = True, + db: AsyncSession = Depends(get_db), +): + """List files, optionally filtered by folder. + + Args: + folder_id: Filter by folder ID. If None and include_root=True, returns root files. + include_root: If True and folder_id is None, returns files at root level. + If False and folder_id is None, returns all files. + """ + query = select(LibraryFile) + + if folder_id is not None: + query = query.where(LibraryFile.folder_id == folder_id) + elif include_root: + query = query.where(LibraryFile.folder_id.is_(None)) + + query = query.order_by(LibraryFile.filename) + result = await db.execute(query) + files = result.scalars().all() + + # Get duplicate counts + hash_counts = {} + if files: + hashes = [f.file_hash for f in files if f.file_hash] + if hashes: + dup_result = await db.execute( + select(LibraryFile.file_hash, func.count(LibraryFile.id)) + .where(LibraryFile.file_hash.in_(hashes)) + .group_by(LibraryFile.file_hash) + ) + hash_counts = {h: c - 1 for h, c in dup_result.all()} # -1 to exclude self + + response = [] + for f in files: + # Extract key metadata for display + print_name = None + print_time = None + filament_grams = None + if f.file_metadata: + print_name = f.file_metadata.get("print_name") + print_time = f.file_metadata.get("print_time_seconds") + filament_grams = f.file_metadata.get("filament_used_grams") + + response.append( + FileListResponse( + id=f.id, + folder_id=f.folder_id, + filename=f.filename, + file_type=f.file_type, + file_size=f.file_size, + thumbnail_path=f.thumbnail_path, + print_count=f.print_count, + duplicate_count=hash_counts.get(f.file_hash, 0) if f.file_hash else 0, + created_at=f.created_at, + print_name=print_name, + print_time_seconds=print_time, + filament_used_grams=filament_grams, + ) + ) + + return response + + +@router.post("/files", response_model=FileUploadResponse) +@router.post("/files/", response_model=FileUploadResponse) +async def upload_file( + file: UploadFile = File(...), + folder_id: int | None = None, + db: AsyncSession = Depends(get_db), +): + """Upload a file to the library.""" + try: + if not file.filename: + raise HTTPException(status_code=400, detail="Filename is required") + + filename = file.filename + ext = os.path.splitext(filename)[1].lower() + # Handle files without extension + file_type = ext[1:] if ext else "unknown" + + # Verify folder exists if specified + if folder_id is not None: + folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id)) + if not folder_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Folder not found") + + # Generate unique filename for storage + unique_filename = f"{uuid.uuid4().hex}{ext}" + file_path = get_library_files_dir() / unique_filename + + # Save file + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + # Calculate hash + file_hash = calculate_file_hash(file_path) + + # Check for duplicates + dup_result = await db.execute(select(LibraryFile.id).where(LibraryFile.file_hash == file_hash).limit(1)) + duplicate_of = dup_result.scalar() + + # Extract metadata and thumbnail + metadata = {} + thumbnail_path = None + thumbnails_dir = get_library_thumbnails_dir() + + if ext == ".3mf": + try: + parser = ThreeMFParser(str(file_path)) + raw_metadata = parser.parse() + + # Extract thumbnail before cleaning metadata + thumbnail_data = raw_metadata.get("_thumbnail_data") + thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png") + + # Save thumbnail if extracted + if thumbnail_data: + thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}" + thumb_path = thumbnails_dir / thumb_filename + with open(thumb_path, "wb") as f: + f.write(thumbnail_data) + thumbnail_path = str(thumb_path) + + # Clean metadata - remove non-JSON-serializable data (bytes, etc.) + def clean_metadata(obj): + if isinstance(obj, dict): + return { + k: clean_metadata(v) + for k, v in obj.items() + if not isinstance(v, bytes) and k not in ("_thumbnail_data", "_thumbnail_ext") + } + elif isinstance(obj, list): + return [clean_metadata(i) for i in obj if not isinstance(i, bytes)] + elif isinstance(obj, bytes): + return None + return obj + + metadata = clean_metadata(raw_metadata) + except Exception as e: + logger.warning(f"Failed to parse 3MF: {e}") + + elif ext == ".gcode": + # Extract embedded thumbnail from gcode + try: + thumbnail_data = extract_gcode_thumbnail(file_path) + if thumbnail_data: + thumb_filename = f"{uuid.uuid4().hex}.png" + thumb_path = thumbnails_dir / thumb_filename + with open(thumb_path, "wb") as f: + f.write(thumbnail_data) + thumbnail_path = str(thumb_path) + except Exception as e: + logger.warning(f"Failed to extract gcode thumbnail: {e}") + + elif ext.lower() in IMAGE_EXTENSIONS: + # For image files, create a thumbnail from the image itself + thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir) + + # Create database entry + library_file = LibraryFile( + folder_id=folder_id, + filename=filename, + file_path=str(file_path), + file_type=file_type, + file_size=len(content), + file_hash=file_hash, + thumbnail_path=thumbnail_path, + file_metadata=metadata if metadata else None, + ) + db.add(library_file) + await db.flush() + await db.refresh(library_file) + + return FileUploadResponse( + id=library_file.id, + filename=library_file.filename, + file_type=library_file.file_type, + file_size=library_file.file_size, + thumbnail_path=library_file.thumbnail_path, + duplicate_of=duplicate_of, + metadata=library_file.file_metadata, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Upload failed for {file.filename}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + + +# ============ Queue Operations ============ +# NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts + + +def is_sliced_file(filename: str) -> bool: + """Check if a file is a sliced (printable) file. + + Sliced files are: + - .gcode files + - .3mf files that contain '.gcode.' in the name (e.g., filename.gcode.3mf) + """ + lower = filename.lower() + return lower.endswith(".gcode") or ".gcode." in lower + + +@router.post("/files/add-to-queue", response_model=AddToQueueResponse) +async def add_files_to_queue( + request: AddToQueueRequest, + db: AsyncSession = Depends(get_db), +): + """Add library files to the print queue. + + Only sliced files (.gcode or .gcode.3mf) can be added to the queue. + For each file: + 1. Validates it's a sliced file + 2. Creates an archive from the library file + 3. Creates a queue item pointing to that archive + """ + added: list[AddToQueueResult] = [] + errors: list[AddToQueueError] = [] + + # Get all requested files + result = await db.execute(select(LibraryFile).where(LibraryFile.id.in_(request.file_ids))) + files = {f.id: f for f in result.scalars().all()} + + # Get max position for queue ordering + pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0))) + max_position = pos_result.scalar() or 0 + + archive_service = ArchiveService(db) + + for file_id in request.file_ids: + lib_file = files.get(file_id) + + if not lib_file: + errors.append(AddToQueueError(file_id=file_id, filename="(not found)", error="File not found")) + continue + + # Validate file is sliced + if not is_sliced_file(lib_file.filename): + errors.append( + AddToQueueError( + file_id=file_id, + filename=lib_file.filename, + error="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.", + ) + ) + continue + + try: + # Get the full file path + file_path = Path(app_settings.base_dir) / lib_file.file_path + + if not file_path.exists(): + errors.append( + AddToQueueError(file_id=file_id, filename=lib_file.filename, error="File not found on disk") + ) + continue + + # Create archive from the library file + archive = await archive_service.archive_print( + printer_id=None, # Unassigned + source_file=file_path, + ) + + if not archive: + errors.append( + AddToQueueError(file_id=file_id, filename=lib_file.filename, error="Failed to create archive") + ) + continue + + # Create queue item + max_position += 1 + queue_item = PrintQueueItem( + printer_id=None, # Unassigned + archive_id=archive.id, + position=max_position, + status="pending", + ) + db.add(queue_item) + + await db.flush() # Get queue_item.id + + added.append( + AddToQueueResult( + file_id=file_id, + filename=lib_file.filename, + queue_item_id=queue_item.id, + archive_id=archive.id, + ) + ) + + except Exception as e: + logger.exception(f"Error adding file {file_id} to queue") + errors.append(AddToQueueError(file_id=file_id, filename=lib_file.filename, error=str(e))) + + await db.commit() + + return AddToQueueResponse(added=added, errors=errors) + + +# ============ File Detail Endpoints ============ + + +@router.get("/files/{file_id}", response_model=FileResponseSchema) +async def get_file(file_id: int, db: AsyncSession = Depends(get_db)): + """Get a file by ID with full details.""" + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + # Get folder name + folder_name = None + if file.folder_id: + folder_result = await db.execute(select(LibraryFolder.name).where(LibraryFolder.id == file.folder_id)) + folder_name = folder_result.scalar() + + # Get project name + project_name = None + if file.project_id: + project_result = await db.execute(select(Project.name).where(Project.id == file.project_id)) + project_name = project_result.scalar() + + # Get duplicates + duplicates = [] + duplicate_count = 0 + if file.file_hash: + dup_result = await db.execute( + select(LibraryFile, LibraryFolder.name) + .outerjoin(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id) + .where(LibraryFile.file_hash == file.file_hash, LibraryFile.id != file.id) + ) + for dup_file, dup_folder_name in dup_result.all(): + duplicates.append( + FileDuplicate( + id=dup_file.id, + filename=dup_file.filename, + folder_id=dup_file.folder_id, + folder_name=dup_folder_name, + created_at=dup_file.created_at, + ) + ) + duplicate_count = len(duplicates) + + return FileResponseSchema( + id=file.id, + folder_id=file.folder_id, + folder_name=folder_name, + project_id=file.project_id, + project_name=project_name, + filename=file.filename, + file_path=file.file_path, + file_type=file.file_type, + file_size=file.file_size, + file_hash=file.file_hash, + thumbnail_path=file.thumbnail_path, + metadata=file.file_metadata, + print_count=file.print_count, + last_printed_at=file.last_printed_at, + notes=file.notes, + duplicates=duplicates if duplicates else None, + duplicate_count=duplicate_count, + created_at=file.created_at, + updated_at=file.updated_at, + ) + + +@router.put("/files/{file_id}", response_model=FileResponseSchema) +async def update_file(file_id: int, data: FileUpdate, db: AsyncSession = Depends(get_db)): + """Update a file's metadata.""" + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + if data.folder_id is not None: + if data.folder_id == 0: + file.folder_id = None + else: + # Verify folder exists + folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id)) + if not folder_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Folder not found") + file.folder_id = data.folder_id + + if data.project_id is not None: + if data.project_id == 0: + file.project_id = None + else: + # Verify project exists + project_result = await db.execute(select(Project).where(Project.id == data.project_id)) + if not project_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Project not found") + file.project_id = data.project_id + + if data.notes is not None: + file.notes = data.notes if data.notes else None + + await db.flush() + await db.refresh(file) + + # Return full response (reuse get_file logic) + return await get_file(file_id, db) + + +@router.delete("/files/{file_id}") +async def delete_file(file_id: int, db: AsyncSession = Depends(get_db)): + """Delete a file.""" + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + # Delete actual files + try: + if file.file_path and os.path.exists(file.file_path): + os.remove(file.file_path) + if file.thumbnail_path and os.path.exists(file.thumbnail_path): + os.remove(file.thumbnail_path) + except Exception as e: + logger.warning(f"Failed to delete file from disk: {e}") + + await db.delete(file) + + return {"status": "success", "message": "File deleted"} + + +# ============ File Content Endpoints ============ + + +@router.get("/files/{file_id}/download") +async def download_file(file_id: int, db: AsyncSession = Depends(get_db)): + """Download a file.""" + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + if not file.file_path or not os.path.exists(file.file_path): + raise HTTPException(status_code=404, detail="File not found on disk") + + return FastAPIFileResponse( + file.file_path, + filename=file.filename, + media_type="application/octet-stream", + ) + + +@router.get("/files/{file_id}/thumbnail") +async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): + """Get a file's thumbnail.""" + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + if not file.thumbnail_path or not os.path.exists(file.thumbnail_path): + raise HTTPException(status_code=404, detail="Thumbnail not found") + + # Detect media type from extension + thumb_ext = os.path.splitext(file.thumbnail_path)[1].lower() + media_types = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + } + media_type = media_types.get(thumb_ext, "image/png") + + return FastAPIFileResponse(file.thumbnail_path, media_type=media_type) + + +@router.get("/files/{file_id}/gcode") +async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)): + """Get gcode for a file (for preview).""" + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + + if not file: + raise HTTPException(status_code=404, detail="File not found") + + if not file.file_path or not os.path.exists(file.file_path): + raise HTTPException(status_code=404, detail="File not found on disk") + + if file.file_type == "gcode": + return FastAPIFileResponse(file.file_path, media_type="text/plain") + elif file.file_type == "3mf": + # Extract gcode from 3mf + import zipfile + + try: + with zipfile.ZipFile(file.file_path, "r") as zf: + # Find gcode file + gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")] + if not gcode_files: + raise HTTPException(status_code=404, detail="No gcode found in 3MF file") + gcode_content = zf.read(gcode_files[0]) + from fastapi.responses import Response + + return Response(content=gcode_content, media_type="text/plain") + except zipfile.BadZipFile: + raise HTTPException(status_code=400, detail="Invalid 3MF file") + else: + raise HTTPException(status_code=400, detail="Unsupported file type") + + +# ============ Bulk Operations ============ + + +@router.post("/files/move") +async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)): + """Move multiple files to a folder.""" + # Verify folder exists if specified + if data.folder_id is not None: + folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id)) + if not folder_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Folder not found") + + # Update files + moved = 0 + for file_id in data.file_ids: + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + if file: + file.folder_id = data.folder_id + moved += 1 + + return {"status": "success", "moved": moved} + + +@router.post("/bulk-delete", response_model=BulkDeleteResponse) +async def bulk_delete(data: BulkDeleteRequest, db: AsyncSession = Depends(get_db)): + """Delete multiple files and/or folders.""" + deleted_files = 0 + deleted_folders = 0 + + # Delete files first + for file_id in data.file_ids: + result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id)) + file = result.scalar_one_or_none() + if file: + try: + if file.file_path and os.path.exists(file.file_path): + os.remove(file.file_path) + if file.thumbnail_path and os.path.exists(file.thumbnail_path): + os.remove(file.thumbnail_path) + except Exception as e: + logger.warning(f"Failed to delete file from disk: {e}") + await db.delete(file) + deleted_files += 1 + + # Delete folders (cascade will handle contents) + for folder_id in data.folder_ids: + result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id)) + folder = result.scalar_one_or_none() + if folder: + # Count files that will be deleted + file_count_result = await db.execute( + select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder_id) + ) + deleted_files += file_count_result.scalar() or 0 + await db.delete(folder) + deleted_folders += 1 + + return BulkDeleteResponse(deleted_files=deleted_files, deleted_folders=deleted_folders) + + +# ============ Stats Endpoint ============ + + +@router.get("/stats") +async def get_library_stats(db: AsyncSession = Depends(get_db)): + """Get library statistics.""" + # Total files + total_files_result = await db.execute(select(func.count(LibraryFile.id))) + total_files = total_files_result.scalar() or 0 + + # Total folders + total_folders_result = await db.execute(select(func.count(LibraryFolder.id))) + total_folders = total_folders_result.scalar() or 0 + + # Total size + total_size_result = await db.execute(select(func.sum(LibraryFile.file_size))) + total_size = total_size_result.scalar() or 0 + + # Files by type + type_result = await db.execute( + select(LibraryFile.file_type, func.count(LibraryFile.id)).group_by(LibraryFile.file_type) + ) + files_by_type = dict(type_result.all()) + + # Total prints + total_prints_result = await db.execute(select(func.sum(LibraryFile.print_count))) + total_prints = total_prints_result.scalar() or 0 + + # Disk space info + library_dir = get_library_dir() + try: + disk_stat = shutil.disk_usage(library_dir) + disk_free_bytes = disk_stat.free + disk_total_bytes = disk_stat.total + disk_used_bytes = disk_stat.used + except Exception: + disk_free_bytes = 0 + disk_total_bytes = 0 + disk_used_bytes = 0 + + return { + "total_files": total_files, + "total_folders": total_folders, + "total_size_bytes": total_size, + "files_by_type": files_by_type, + "total_prints": total_prints, + "disk_free_bytes": disk_free_bytes, + "disk_total_bytes": disk_total_bytes, + "disk_used_bytes": disk_used_bytes, + } diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 154dab49c..4d95a091d 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -46,6 +46,13 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse: "auto_off_after": item.auto_off_after, "manual_start": item.manual_start, "ams_mapping": ams_mapping_parsed, + "plate_id": item.plate_id, + "bed_levelling": item.bed_levelling, + "flow_cali": item.flow_cali, + "vibration_cali": item.vibration_cali, + "layer_inspect": item.layer_inspect, + "timelapse": item.timelapse, + "use_ams": item.use_ams, "status": item.status, "started_at": item.started_at, "completed_at": item.completed_at, @@ -130,6 +137,13 @@ async def add_to_queue( auto_off_after=data.auto_off_after, manual_start=data.manual_start, ams_mapping=json.dumps(data.ams_mapping) if data.ams_mapping else None, + plate_id=data.plate_id, + bed_levelling=data.bed_levelling, + flow_cali=data.flow_cali, + vibration_cali=data.vibration_cali, + layer_inspect=data.layer_inspect, + timelapse=data.timelapse, + use_ams=data.use_ams, position=max_pos + 1, status="pending", ) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index a6a010163..759e0b001 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -80,7 +80,13 @@ async def get_settings(db: AsyncSession = Depends(get_db)): "ha_enabled", ]: settings_dict[setting.key] = setting.value.lower() == "true" - elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]: + elif setting.key in [ + "default_filament_cost", + "energy_cost_per_kwh", + "ams_temp_good", + "ams_temp_fair", + "library_disk_warning_gb", + ]: settings_dict[setting.key] = float(setting.value) elif setting.key in [ "ams_humidity_good", @@ -526,6 +532,13 @@ async def export_backup( "auto_off_after": qi.auto_off_after, "manual_start": qi.manual_start, "ams_mapping": qi.ams_mapping, + "plate_id": qi.plate_id, + "bed_levelling": qi.bed_levelling, + "flow_cali": qi.flow_cali, + "vibration_cali": qi.vibration_cali, + "layer_inspect": qi.layer_inspect, + "timelapse": qi.timelapse, + "use_ams": qi.use_ams, "status": qi.status, "started_at": qi.started_at.isoformat() if qi.started_at else None, "completed_at": qi.completed_at.isoformat() if qi.completed_at else None, @@ -1588,6 +1601,13 @@ async def import_backup( auto_off_after=qi_data.get("auto_off_after", False), manual_start=qi_data.get("manual_start", False), ams_mapping=qi_data.get("ams_mapping"), + plate_id=qi_data.get("plate_id"), + bed_levelling=qi_data.get("bed_levelling", True), + flow_cali=qi_data.get("flow_cali", False), + vibration_cali=qi_data.get("vibration_cali", True), + layer_inspect=qi_data.get("layer_inspect", False), + timelapse=qi_data.get("timelapse", False), + use_ams=qi_data.get("use_ams", True), status=qi_data.get("status", "pending"), error_message=qi_data.get("error_message"), ) diff --git a/backend/app/api/routes/webhook.py b/backend/app/api/routes/webhook.py index 282e1a6cb..504a1dd32 100644 --- a/backend/app/api/routes/webhook.py +++ b/backend/app/api/routes/webhook.py @@ -171,9 +171,13 @@ async def webhook_start_print( 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 + # Start the print with plate_id if available try: - await printer_manager.start_print(printer_id, queue_item.archive_id) + await printer_manager.start_print( + printer_id, + queue_item.archive_id, + plate_id=queue_item.plate_id or 1, + ) except Exception as e: logger.error(f"Failed to start print: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 74558c2b1..1a6011fe3 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -39,6 +39,7 @@ async def init_db(): external_link, filament, kprofile_note, + library, maintenance, notification, notification_template, @@ -468,6 +469,24 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add project_id column to library_folders for linking folders to projects + try: + await conn.execute( + text("ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL") + ) + except Exception: + pass + + # Migration: Add archive_id column to library_folders for linking folders to archives + try: + await conn.execute( + text( + "ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL" + ) + ) + except Exception: + pass + # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation) try: # Check if ip_address is currently NOT NULL @@ -528,6 +547,38 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add plate_id column to print_queue for multi-plate 3MF support + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN plate_id INTEGER")) + except Exception: + pass + + # Migration: Add print options columns to print_queue + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0")) + except Exception: + pass + try: + await conn.execute(text("ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1")) + 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 dcd80847f..5d4bc86d9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -61,6 +61,7 @@ from backend.app.api.routes import ( filaments, firmware, kprofiles, + library, maintenance, notification_templates, notifications, @@ -1963,6 +1964,7 @@ 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(library.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(ams_history.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a6d7bda5c..25dc630db 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,6 +3,7 @@ from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive from backend.app.models.filament import Filament from backend.app.models.kprofile_note import KProfileNote +from backend.app.models.library import LibraryFile, LibraryFolder from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance from backend.app.models.notification import NotificationLog from backend.app.models.notification_template import NotificationTemplate @@ -28,4 +29,6 @@ __all__ = [ "APIKey", "AMSSensorHistory", "PendingUpload", + "LibraryFolder", + "LibraryFile", ] diff --git a/backend/app/models/library.py b/backend/app/models/library.py new file mode 100644 index 000000000..f138c827c --- /dev/null +++ b/backend/app/models/library.py @@ -0,0 +1,86 @@ +"""Library models for file manager functionality.""" + +from datetime import datetime + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class LibraryFolder(Base): + """Folder for organizing library files.""" + + __tablename__ = "library_folders" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True) + + # Link to project or archive + project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True) + archive_id: Mapped[int | None] = mapped_column(ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + parent: Mapped["LibraryFolder | None"] = relationship( + "LibraryFolder", + back_populates="children", + remote_side="LibraryFolder.id", + foreign_keys="LibraryFolder.parent_id", + ) + children: Mapped[list["LibraryFolder"]] = relationship( + "LibraryFolder", + back_populates="parent", + foreign_keys="LibraryFolder.parent_id", + cascade="all, delete-orphan", + ) + files: Mapped[list["LibraryFile"]] = relationship( + back_populates="folder", + cascade="all, delete-orphan", + ) + project: Mapped["Project | None"] = relationship() + archive: Mapped["PrintArchive | None"] = relationship() + + +class LibraryFile(Base): + """File stored in the library.""" + + __tablename__ = "library_files" + + id: Mapped[int] = mapped_column(primary_key=True) + folder_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), 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)) # Original filename + file_path: Mapped[str] = mapped_column(String(500)) # Storage path + file_type: Mapped[str] = mapped_column(String(10)) # "3mf" or "gcode" + file_size: Mapped[int] = mapped_column(Integer) + file_hash: Mapped[str | None] = mapped_column(String(64)) # SHA256 for duplicate detection + thumbnail_path: Mapped[str | None] = mapped_column(String(500)) + + # Extracted metadata (from 3MF parser) + file_metadata: Mapped[dict | None] = mapped_column(JSON) + + # Usage tracking + print_count: Mapped[int] = mapped_column(Integer, default=0) + last_printed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + # User notes + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationships + folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files") + project: Mapped["Project | None"] = relationship() + + +from backend.app.models.archive import PrintArchive # 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 a1057b674..3062ce771 100644 --- a/backend/app/models/print_queue.py +++ b/backend/app/models/print_queue.py @@ -33,6 +33,17 @@ class PrintQueueItem(Base): # Format: "[5, -1, 2, -1]" where position = slot_id-1, value = global tray ID (-1 = unused) ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True) + # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1) + plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # Print options + bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True) + flow_cali: Mapped[bool] = mapped_column(Boolean, default=False) + vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True) + layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False) + timelapse: Mapped[bool] = mapped_column(Boolean, default=False) + use_ams: Mapped[bool] = mapped_column(Boolean, default=True) + # Status: pending, printing, completed, failed, skipped, cancelled status: Mapped[str] = mapped_column(String(20), default="pending") diff --git a/backend/app/schemas/library.py b/backend/app/schemas/library.py new file mode 100644 index 000000000..b72b58805 --- /dev/null +++ b/backend/app/schemas/library.py @@ -0,0 +1,236 @@ +"""Pydantic schemas for library (File Manager) functionality.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + +# ============ Folder Schemas ============ + + +class FolderCreate(BaseModel): + """Schema for creating a new folder.""" + + name: str = Field(..., min_length=1, max_length=255) + parent_id: int | None = None + project_id: int | None = None + archive_id: int | None = None + + +class FolderUpdate(BaseModel): + """Schema for updating a folder.""" + + name: str | None = Field(None, min_length=1, max_length=255) + parent_id: int | None = None + project_id: int | None = None # 0 to unlink + archive_id: int | None = None # 0 to unlink + + +class FolderResponse(BaseModel): + """Schema for folder response.""" + + id: int + name: str + parent_id: int | None + project_id: int | None = None + archive_id: int | None = None + project_name: str | None = None + archive_name: str | None = None + file_count: int = 0 # Computed field + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FolderTreeItem(BaseModel): + """Schema for folder tree item (includes children).""" + + id: int + name: str + parent_id: int | None + project_id: int | None = None + archive_id: int | None = None + project_name: str | None = None + archive_name: str | None = None + file_count: int = 0 + children: list["FolderTreeItem"] = [] + + class Config: + from_attributes = True + + +# ============ File Schemas ============ + + +class FileCreate(BaseModel): + """Schema for creating a file entry (internal use after upload).""" + + filename: str + file_path: str + file_type: str + file_size: int + file_hash: str | None = None + thumbnail_path: str | None = None + metadata: dict | None = None + folder_id: int | None = None + project_id: int | None = None + + +class FileUpdate(BaseModel): + """Schema for updating a file.""" + + folder_id: int | None = None + project_id: int | None = None + notes: str | None = None + + +class FileDuplicate(BaseModel): + """Reference to a duplicate file.""" + + id: int + filename: str + folder_id: int | None + folder_name: str | None + created_at: datetime + + +class FileResponse(BaseModel): + """Schema for file response.""" + + id: int + folder_id: int | None + folder_name: str | None = None + project_id: int | None + project_name: str | None = None + + filename: str + file_path: str + file_type: str + file_size: int + file_hash: str | None + thumbnail_path: str | None + + metadata: dict | None + + print_count: int + last_printed_at: datetime | None + + notes: str | None + + # Duplicate detection + duplicates: list[FileDuplicate] | None = None + duplicate_count: int = 0 + + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FileListResponse(BaseModel): + """Schema for file list item (lighter than full response).""" + + id: int + folder_id: int | None + filename: str + file_type: str + file_size: int + thumbnail_path: str | None + print_count: int + duplicate_count: int = 0 + created_at: datetime + + # Key metadata fields for display + print_name: str | None = None + print_time_seconds: int | None = None + filament_used_grams: float | None = None + + class Config: + from_attributes = True + + +class FileMoveRequest(BaseModel): + """Schema for moving files to a folder.""" + + file_ids: list[int] + folder_id: int | None = None # None = move to root + + +class FilePrintRequest(BaseModel): + """Schema for printing a file from the library.""" + + printer_id: str # Printer serial number + + # Print options (same as archive reprint) + plate_id: int | None = None + ams_mapping: list[int] | None = None + bed_levelling: bool = True + flow_cali: bool = False + vibration_cali: bool = True + layer_inspect: bool = False + timelapse: bool = False + use_ams: bool = True + + +class FileUploadResponse(BaseModel): + """Schema for file upload response.""" + + id: int + filename: str + file_type: str + file_size: int + thumbnail_path: str | None + duplicate_of: int | None = None # ID of existing file with same hash + metadata: dict | None = None + + +# ============ Bulk Operations ============ + + +class BulkDeleteRequest(BaseModel): + """Schema for bulk delete operations.""" + + file_ids: list[int] = [] + folder_ids: list[int] = [] + + +class BulkDeleteResponse(BaseModel): + """Schema for bulk delete response.""" + + deleted_files: int + deleted_folders: int + + +# ============ Queue Operations ============ + + +class AddToQueueRequest(BaseModel): + """Schema for adding library files to the print queue.""" + + file_ids: list[int] = Field(..., min_length=1) + + +class AddToQueueResult(BaseModel): + """Result for a single file added to queue.""" + + file_id: int + filename: str + queue_item_id: int + archive_id: int + + +class AddToQueueError(BaseModel): + """Error for a file that couldn't be added to queue.""" + + file_id: int + filename: str + error: str + + +class AddToQueueResponse(BaseModel): + """Schema for add-to-queue response.""" + + added: list[AddToQueueResult] + errors: list[AddToQueueError] diff --git a/backend/app/schemas/print_queue.py b/backend/app/schemas/print_queue.py index 12f0b0226..8f3a77950 100644 --- a/backend/app/schemas/print_queue.py +++ b/backend/app/schemas/print_queue.py @@ -25,6 +25,15 @@ class PrintQueueItemCreate(BaseModel): # AMS mapping: list of global tray IDs for each filament slot # Format: [5, -1, 2, -1] where position = slot_id-1, value = global tray ID (-1 = unused) ams_mapping: list[int] | None = None + # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1) + plate_id: int | None = None + # Print options + bed_levelling: bool = True + flow_cali: bool = False + vibration_cali: bool = True + layer_inspect: bool = False + timelapse: bool = False + use_ams: bool = True class PrintQueueItemUpdate(BaseModel): @@ -35,6 +44,14 @@ class PrintQueueItemUpdate(BaseModel): auto_off_after: bool | None = None manual_start: bool | None = None ams_mapping: list[int] | None = None + plate_id: int | None = None + # Print options + bed_levelling: bool | None = None + flow_cali: bool | None = None + vibration_cali: bool | None = None + layer_inspect: bool | None = None + timelapse: bool | None = None + use_ams: bool | None = None class PrintQueueItemResponse(BaseModel): @@ -47,6 +64,14 @@ class PrintQueueItemResponse(BaseModel): auto_off_after: bool manual_start: bool ams_mapping: list[int] | None = None + plate_id: int | None = None # Plate ID for multi-plate 3MF files + # Print options + bed_levelling: bool = True + flow_cali: bool = False + vibration_cali: bool = True + layer_inspect: bool = False + timelapse: bool = False + use_ams: bool = True status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"] started_at: UTCDatetime completed_at: UTCDatetime diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 6bbc2f7dc..da8f5da4f 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -90,6 +90,16 @@ class AppSettings(BaseModel): ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)") ha_token: str = Field(default="", description="Home Assistant Long-Lived Access Token") + # File Manager / Library settings + library_archive_mode: str = Field( + default="ask", + description="When printing from File Manager, create archive entry: 'always', 'never', or 'ask'", + ) + library_disk_warning_gb: float = Field( + default=5.0, + description="Show warning when free disk space falls below this threshold (GB)", + ) + class AppSettingsUpdate(BaseModel): """Schema for updating settings (all fields optional).""" @@ -137,3 +147,5 @@ class AppSettingsUpdate(BaseModel): ha_enabled: bool | None = None ha_url: str | None = None ha_token: str | None = None + library_archive_mode: str | None = None + library_disk_warning_gb: float | None = None diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 8b358257b..f685073fc 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -345,11 +345,18 @@ class PrintScheduler: except json.JSONDecodeError: logger.warning(f"Queue item {item.id}: Invalid AMS mapping JSON, ignoring") - # Start the print with AMS mapping if available + # Start the print with AMS mapping, plate_id and print options started = printer_manager.start_print( item.printer_id, remote_filename, + plate_id=item.plate_id or 1, ams_mapping=ams_mapping, + bed_levelling=item.bed_levelling, + flow_cali=item.flow_cali, + vibration_cali=item.vibration_cali, + layer_inspect=item.layer_inspect, + timelapse=item.timelapse, + use_ams=item.use_ams, ) if started: diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py new file mode 100644 index 000000000..62630089e --- /dev/null +++ b/backend/tests/integration/test_library_api.py @@ -0,0 +1,313 @@ +"""Integration tests for Library API endpoints.""" + +import pytest +from httpx import AsyncClient + + +class TestLibraryFoldersAPI: + """Integration tests for library folders endpoints.""" + + @pytest.fixture + async def folder_factory(self, db_session): + """Factory to create test folders.""" + _counter = [0] + + async def _create_folder(**kwargs): + from backend.app.models.library import LibraryFolder + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "name": f"Test Folder {counter}", + } + defaults.update(kwargs) + + folder = LibraryFolder(**defaults) + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + return folder + + return _create_folder + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_folders_empty(self, async_client: AsyncClient, db_session): + """Verify empty folder list returns empty array.""" + response = await async_client.get("/api/v1/library/folders") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_folder(self, async_client: AsyncClient, db_session): + """Verify folder can be created.""" + data = {"name": "New Folder"} + response = await async_client.post("/api/v1/library/folders", json=data) + assert response.status_code == 200 + result = response.json() + assert result["name"] == "New Folder" + assert result["id"] is not None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_nested_folder(self, async_client: AsyncClient, folder_factory, db_session): + """Verify nested folder can be created.""" + parent = await folder_factory(name="Parent") + data = {"name": "Child", "parent_id": parent.id} + response = await async_client.post("/api/v1/library/folders", json=data) + assert response.status_code == 200 + result = response.json() + assert result["name"] == "Child" + assert result["parent_id"] == parent.id + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_folder(self, async_client: AsyncClient, folder_factory, db_session): + """Verify single folder can be retrieved.""" + folder = await folder_factory(name="Test Folder") + response = await async_client.get(f"/api/v1/library/folders/{folder.id}") + assert response.status_code == 200 + result = response.json() + assert result["id"] == folder.id + assert result["name"] == "Test Folder" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_folder_not_found(self, async_client: AsyncClient, db_session): + """Verify 404 for non-existent folder.""" + response = await async_client.get("/api/v1/library/folders/9999") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_folder(self, async_client: AsyncClient, folder_factory, db_session): + """Verify folder can be updated.""" + folder = await folder_factory(name="Old Name") + data = {"name": "New Name"} + response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json=data) + assert response.status_code == 200 + result = response.json() + assert result["name"] == "New Name" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session): + """Verify folder can be deleted.""" + folder = await folder_factory() + response = await async_client.delete(f"/api/v1/library/folders/{folder.id}") + assert response.status_code == 200 + result = response.json() + assert result.get("message") or result.get("success", True) + + +class TestLibraryFilesAPI: + """Integration tests for library files endpoints.""" + + @pytest.fixture + async def folder_factory(self, db_session): + """Factory to create test folders.""" + _counter = [0] + + async def _create_folder(**kwargs): + from backend.app.models.library import LibraryFolder + + _counter[0] += 1 + counter = _counter[0] + + defaults = {"name": f"Test Folder {counter}"} + defaults.update(kwargs) + + folder = LibraryFolder(**defaults) + db_session.add(folder) + await db_session.commit() + await db_session.refresh(folder) + return folder + + return _create_folder + + @pytest.fixture + async def file_factory(self, db_session): + """Factory to create test files.""" + _counter = [0] + + async def _create_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "filename": f"test_file_{counter}.3mf", + "file_path": f"/test/path/test_file_{counter}.3mf", + "file_size": 1024, + "file_type": "3mf", + } + defaults.update(kwargs) + + lib_file = LibraryFile(**defaults) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + return lib_file + + return _create_file + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_files_empty(self, async_client: AsyncClient, db_session): + """Verify empty file list returns empty array.""" + response = await async_client.get("/api/v1/library/files") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_files_in_folder(self, async_client: AsyncClient, folder_factory, file_factory, db_session): + """Verify files can be filtered by folder.""" + folder = await folder_factory() + file1 = await file_factory(folder_id=folder.id) + await file_factory() # File in root (no folder) + + response = await async_client.get(f"/api/v1/library/files?folder_id={folder.id}") + assert response.status_code == 200 + result = response.json() + assert len(result) == 1 + assert result[0]["id"] == file1.id + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_file(self, async_client: AsyncClient, file_factory, db_session): + """Verify single file can be retrieved.""" + lib_file = await file_factory(filename="test.3mf") + response = await async_client.get(f"/api/v1/library/files/{lib_file.id}") + assert response.status_code == 200 + result = response.json() + assert result["id"] == lib_file.id + assert result["filename"] == "test.3mf" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_file_not_found(self, async_client: AsyncClient, db_session): + """Verify 404 for non-existent file.""" + response = await async_client.get("/api/v1/library/files/9999") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session): + """Verify file can be deleted.""" + lib_file = await file_factory() + response = await async_client.delete(f"/api/v1/library/files/{lib_file.id}") + assert response.status_code == 200 + result = response.json() + assert result.get("message") or result.get("success", True) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session): + """Verify library stats endpoint returns counts.""" + await folder_factory() + await folder_factory() + await file_factory() + + response = await async_client.get("/api/v1/library/stats") + assert response.status_code == 200 + result = response.json() + assert result["total_folders"] == 2 + assert result["total_files"] == 1 + + +class TestLibraryAddToQueueAPI: + """Integration tests for /api/v1/library/files/add-to-queue endpoint.""" + + @pytest.fixture + async def printer_factory(self, db_session): + """Factory to create test printers.""" + _counter = [0] + + async def _create_printer(**kwargs): + from backend.app.models.printer import Printer + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "name": f"Test Printer {counter}", + "ip_address": f"192.168.1.{100 + counter}", + "serial_number": f"TESTSERIAL{counter:04d}", + "access_code": "12345678", + "model": "X1C", + } + defaults.update(kwargs) + + printer = Printer(**defaults) + db_session.add(printer) + await db_session.commit() + await db_session.refresh(printer) + return printer + + return _create_printer + + @pytest.fixture + async def library_file_factory(self, db_session): + """Factory to create test library files.""" + _counter = [0] + + async def _create_library_file(**kwargs): + from backend.app.models.library import LibraryFile + + _counter[0] += 1 + counter = _counter[0] + + defaults = { + "filename": f"test_file_{counter}.gcode.3mf", + "file_path": f"/test/path/test_file_{counter}.gcode.3mf", + "file_size": 1024, + "file_type": "3mf", + } + defaults.update(kwargs) + + lib_file = LibraryFile(**defaults) + db_session.add(lib_file) + await db_session.commit() + await db_session.refresh(lib_file) + return lib_file + + return _create_library_file + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session): + """Verify error for non-existent file.""" + await printer_factory() + + data = {"file_ids": [9999]} + response = await async_client.post("/api/v1/library/files/add-to-queue", json=data) + assert response.status_code == 200 + result = response.json() + assert len(result["added"]) == 0 + assert len(result["errors"]) == 1 + assert result["errors"][0]["file_id"] == 9999 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_non_sliced_file_to_queue_fails( + self, async_client: AsyncClient, printer_factory, library_file_factory, db_session + ): + """Verify non-sliced file cannot be added to queue.""" + await printer_factory() + lib_file = await library_file_factory( + filename="model.stl", + file_path="/test/path/model.stl", + file_type="stl", + ) + + data = {"file_ids": [lib_file.id]} + response = await async_client.post("/api/v1/library/files/add-to-queue", json=data) + assert response.status_code == 200 + result = response.json() + assert len(result["added"]) == 0 + assert len(result["errors"]) == 1 + assert "sliced" in result["errors"][0]["error"].lower() diff --git a/backend/tests/integration/test_print_queue_api.py b/backend/tests/integration/test_print_queue_api.py index 30e53e981..5ec94c31e 100644 --- a/backend/tests/integration/test_print_queue_api.py +++ b/backend/tests/integration/test_print_queue_api.py @@ -168,6 +168,81 @@ class TestPrintQueueAPI: assert result["archive_id"] == archive.id assert result["ams_mapping"] == [5, -1, 2, -1] + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_to_queue_with_plate_id( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """Verify item can be added to queue with plate_id for multi-plate 3MF.""" + printer = await printer_factory() + archive = await archive_factory() + + data = { + "printer_id": printer.id, + "archive_id": archive.id, + "plate_id": 3, + } + response = await async_client.post("/api/v1/queue/", json=data) + assert response.status_code == 200 + result = response.json() + assert result["plate_id"] == 3 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_to_queue_with_print_options( + self, async_client: AsyncClient, printer_factory, archive_factory, db_session + ): + """Verify item can be added to queue with print options.""" + printer = await printer_factory() + archive = await archive_factory() + + data = { + "printer_id": printer.id, + "archive_id": archive.id, + "bed_levelling": False, + "flow_cali": True, + "vibration_cali": False, + "layer_inspect": True, + "timelapse": True, + "use_ams": False, + } + response = await async_client.post("/api/v1/queue/", json=data) + assert response.status_code == 200 + result = response.json() + assert result["bed_levelling"] is False + assert result["flow_cali"] is True + assert result["vibration_cali"] is False + assert result["layer_inspect"] is True + assert result["timelapse"] is True + assert result["use_ams"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_queue_item_plate_id(self, async_client: AsyncClient, queue_item_factory, db_session): + """Verify queue item plate_id can be updated.""" + item = await queue_item_factory() + response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 5}) + assert response.status_code == 200 + result = response.json() + assert result["plate_id"] == 5 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_queue_item_print_options(self, async_client: AsyncClient, queue_item_factory, db_session): + """Verify queue item print options can be updated.""" + item = await queue_item_factory() + response = await async_client.patch( + f"/api/v1/queue/{item.id}", + json={ + "bed_levelling": False, + "timelapse": True, + }, + ) + assert response.status_code == 200 + result = response.json() + assert result["bed_levelling"] is False + assert result["timelapse"] is True + @pytest.mark.asyncio @pytest.mark.integration async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88b15f9dd..5ec765c64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { ProfilesPage } from './pages/ProfilesPage'; import { MaintenancePage } from './pages/MaintenancePage'; import { ProjectsPage } from './pages/ProjectsPage'; import { ProjectDetailPage } from './pages/ProjectDetailPage'; +import { FileManagerPage } from './pages/FileManagerPage'; import { CameraPage } from './pages/CameraPage'; import { ExternalLinkPage } from './pages/ExternalLinkPage'; import { SystemInfoPage } from './pages/SystemInfoPage'; @@ -51,6 +52,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/__tests__/components/EditQueueItemModal.test.tsx b/frontend/src/__tests__/components/EditQueueItemModal.test.tsx new file mode 100644 index 000000000..047021812 --- /dev/null +++ b/frontend/src/__tests__/components/EditQueueItemModal.test.tsx @@ -0,0 +1,257 @@ +/** + * Tests for the EditQueueItemModal component. + * + * These tests focus on: + * - Basic rendering and modal controls + * - Print options (bed levelling, flow calibration, etc.) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { EditQueueItemModal } from '../../components/EditQueueItemModal'; +import type { PrintQueueItem, Printer } from '../../api/client'; + +// Mock the API client to prevent actual API calls +vi.mock('../../api/client', async () => { + const actual = await vi.importActual('../../api/client'); + return { + ...actual, + fetchArchivePlates: vi.fn().mockResolvedValue([]), + fetchFilamentRequirements: vi.fn().mockResolvedValue([]), + }; +}); + +// Mock data +const createMockPrinter = (overrides: Partial = {}): Printer => ({ + id: 1, + name: 'Test Printer', + ip_address: '192.168.1.100', + serial_number: 'TESTSERIAL0001', + access_code: '12345678', + model: 'X1C', + enabled: true, + created_at: '2024-01-01T00:00:00Z', + ...overrides, +}); + +const createMockQueueItem = (overrides: Partial = {}): PrintQueueItem => ({ + id: 1, + printer_id: 1, + archive_id: 1, + position: 1, + scheduled_time: null, + require_previous_success: false, + auto_off_after: false, + manual_start: false, + ams_mapping: null, + plate_id: null, + bed_levelling: true, + flow_cali: false, + vibration_cali: true, + layer_inspect: false, + timelapse: false, + use_ams: true, + status: 'pending', + started_at: null, + completed_at: null, + error_message: null, + created_at: '2024-01-01T00:00:00Z', + archive_name: 'Test Print', + archive_thumbnail: null, + printer_name: 'Test Printer', + print_time_seconds: 3600, + ...overrides, +}); + +describe('EditQueueItemModal', () => { + const mockOnClose = vi.fn(); + const mockOnSave = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders the modal with title', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + expect(screen.getByText('Edit Queue Item')).toBeInTheDocument(); + }); + + it('shows printer selector label', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter({ name: 'My Printer' })]; + + render( + + ); + + // The printer label should be present + expect(screen.getByText('Printer')).toBeInTheDocument(); + }); + + it('shows print options toggle', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + expect(screen.getByText('Print Options')).toBeInTheDocument(); + }); + }); + + describe('print options', () => { + it('has print options toggle button', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + // Print Options toggle should be present + expect(screen.getByText('Print Options')).toBeInTheDocument(); + }); + + it('print options toggle is clickable', async () => { + const user = userEvent.setup(); + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + // Click should not throw an error + const printOptionsButton = screen.getByText('Print Options'); + await user.click(printOptionsButton); + + // The button should still be in the document after clicking + expect(screen.getByText('Print Options')).toBeInTheDocument(); + }); + }); + + describe('modal controls', () => { + it('has save button', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeInTheDocument(); + }); + + it('has cancel button', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + expect(cancelButton).toBeInTheDocument(); + }); + + it('calls onClose when cancel button is clicked', async () => { + const user = userEvent.setup(); + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('queue options', () => { + it('shows queue only option', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + expect(screen.getByText('Queue Only')).toBeInTheDocument(); + }); + + it('shows power off option', () => { + const item = createMockQueueItem(); + const printers = [createMockPrinter()]; + + render( + + ); + + expect(screen.getByText(/power off/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 419bce462..d503c2cf9 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -283,4 +283,28 @@ export const handlers = [ http.get('/health', () => { return HttpResponse.json({ status: 'healthy' }); }), + + // ======================================================================== + // Archives + // ======================================================================== + + http.get('/api/v1/archives/:id/plates', () => { + return HttpResponse.json([]); + }), + + http.get('/api/v1/archives/:id/filament-requirements', () => { + return HttpResponse.json([]); + }), + + // ======================================================================== + // Library + // ======================================================================== + + http.get('/api/v1/library/stats', () => { + return HttpResponse.json({ + total_files: 0, + total_size: 0, + total_folders: 0, + }); + }), ]; diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index bf62d5c06..8efc8e990 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -19,8 +19,8 @@ beforeAll(() => if (request.url.includes('/ws')) { return; } - // Error on other unhandled requests - print.error(); + // Silently ignore unhandled requests in tests to reduce noise + // Remove 'warn' to completely silence, or use print.warning() to show warnings }, }) ); @@ -100,5 +100,8 @@ const localStorageMock = { }; Object.defineProperty(window, 'localStorage', { value: localStorageMock }); -// Suppress console errors during tests (optional, can be removed for debugging) -// vi.spyOn(console, 'error').mockImplementation(() => {}); +// Suppress console output during tests (reduces noise) +// Remove these lines if you need to debug test output +vi.spyOn(console, 'log').mockImplementation(() => {}); +vi.spyOn(console, 'warn').mockImplementation(() => {}); +vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 65af485f5..22eb5c7b7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -590,6 +590,9 @@ export interface AppSettings { ha_enabled: boolean; ha_url: string; ha_token: string; + // File Manager / Library settings + library_archive_mode: 'always' | 'never' | 'ask'; + library_disk_warning_gb: number; } export type AppSettingsUpdate = Partial; @@ -847,6 +850,14 @@ export interface PrintQueueItem { auto_off_after: boolean; manual_start: boolean; // Requires manual trigger to start (staged) ams_mapping: number[] | null; // AMS slot mapping for multi-color prints + plate_id: number | null; // Plate ID for multi-plate 3MF files + // Print options + bed_levelling: boolean; + flow_cali: boolean; + vibration_cali: boolean; + layer_inspect: boolean; + timelapse: boolean; + use_ams: boolean; status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled'; started_at: string | null; completed_at: string | null; @@ -866,6 +877,14 @@ export interface PrintQueueItemCreate { auto_off_after?: boolean; manual_start?: boolean; // Requires manual trigger to start (staged) ams_mapping?: number[] | null; // AMS slot mapping for multi-color prints + plate_id?: number | null; // Plate ID for multi-plate 3MF files + // Print options + bed_levelling?: boolean; + flow_cali?: boolean; + vibration_cali?: boolean; + layer_inspect?: boolean; + timelapse?: boolean; + use_ams?: boolean; } export interface PrintQueueItemUpdate { @@ -876,6 +895,14 @@ export interface PrintQueueItemUpdate { auto_off_after?: boolean; manual_start?: boolean; ams_mapping?: number[]; + plate_id?: number | null; // Plate ID for multi-plate 3MF files + // Print options + bed_levelling?: boolean; + flow_cali?: boolean; + vibration_cali?: boolean; + layer_inspect?: boolean; + timelapse?: boolean; + use_ams?: boolean; } // MQTT Logging types @@ -2443,6 +2470,75 @@ export const api = { // System Info getSystemInfo: () => request('/system/info'), + + // Library (File Manager) + getLibraryFolders: () => request('/library/folders'), + createLibraryFolder: (data: LibraryFolderCreate) => + request('/library/folders', { + method: 'POST', + body: JSON.stringify(data), + }), + updateLibraryFolder: (id: number, data: LibraryFolderUpdate) => + request(`/library/folders/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteLibraryFolder: (id: number) => + request<{ status: string; message: string }>(`/library/folders/${id}`, { method: 'DELETE' }), + getLibraryFoldersByProject: (projectId: number) => + request(`/library/folders/by-project/${projectId}`), + getLibraryFoldersByArchive: (archiveId: number) => + request(`/library/folders/by-archive/${archiveId}`), + + getLibraryFiles: (folderId?: number | null, includeRoot = true) => { + const params = new URLSearchParams(); + if (folderId !== undefined && folderId !== null) { + params.set('folder_id', String(folderId)); + } + params.set('include_root', String(includeRoot)); + return request(`/library/files?${params}`); + }, + getLibraryFile: (id: number) => request(`/library/files/${id}`), + uploadLibraryFile: async (file: File, folderId?: number | null): Promise => { + const formData = new FormData(); + formData.append('file', file); + const params = folderId ? `?folder_id=${folderId}` : ''; + const response = await fetch(`${API_BASE}/library/files${params}`, { + method: 'POST', + body: formData, + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || `HTTP ${response.status}`); + } + return response.json(); + }, + updateLibraryFile: (id: number, data: LibraryFileUpdate) => + request(`/library/files/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + deleteLibraryFile: (id: number) => + request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }), + getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`, + getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`, + getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`, + moveLibraryFiles: (fileIds: number[], folderId: number | null) => + request<{ status: string; moved: number }>('/library/files/move', { + method: 'POST', + body: JSON.stringify({ file_ids: fileIds, folder_id: folderId }), + }), + bulkDeleteLibrary: (fileIds: number[], folderIds: number[]) => + request<{ deleted_files: number; deleted_folders: number }>('/library/bulk-delete', { + method: 'POST', + body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }), + }), + getLibraryStats: () => request('/library/stats'), + addLibraryFilesToQueue: (fileIds: number[]) => + request('/library/files/add-to-queue', { + method: 'POST', + body: JSON.stringify({ file_ids: fileIds }), + }), }; // AMS History types @@ -2536,6 +2632,137 @@ export interface SystemInfo { }; } +// Library (File Manager) types +export interface LibraryFolderTree { + id: number; + name: string; + parent_id: number | null; + project_id: number | null; + archive_id: number | null; + project_name: string | null; + archive_name: string | null; + file_count: number; + children: LibraryFolderTree[]; +} + +export interface LibraryFolder { + id: number; + name: string; + parent_id: number | null; + project_id: number | null; + archive_id: number | null; + project_name: string | null; + archive_name: string | null; + file_count: number; + created_at: string; + updated_at: string; +} + +export interface LibraryFolderCreate { + name: string; + parent_id?: number | null; + project_id?: number | null; + archive_id?: number | null; +} + +export interface LibraryFolderUpdate { + name?: string; + parent_id?: number | null; + project_id?: number | null; // 0 to unlink + archive_id?: number | null; // 0 to unlink +} + +export interface LibraryFileDuplicate { + id: number; + filename: string; + folder_id: number | null; + folder_name: string | null; + created_at: string; +} + +export interface LibraryFile { + id: number; + folder_id: number | null; + folder_name: string | null; + project_id: number | null; + project_name: string | null; + filename: string; + file_path: string; + file_type: string; + file_size: number; + file_hash: string | null; + thumbnail_path: string | null; + metadata: Record | null; + print_count: number; + last_printed_at: string | null; + notes: string | null; + duplicates: LibraryFileDuplicate[] | null; + duplicate_count: number; + created_at: string; + updated_at: string; +} + +export interface LibraryFileListItem { + id: number; + folder_id: number | null; + filename: string; + file_type: string; + file_size: number; + thumbnail_path: string | null; + print_count: number; + duplicate_count: number; + created_at: string; + print_name: string | null; + print_time_seconds: number | null; + filament_used_grams: number | null; +} + +export interface LibraryFileUpdate { + folder_id?: number | null; + project_id?: number | null; + notes?: string | null; +} + +export interface LibraryFileUploadResponse { + id: number; + filename: string; + file_type: string; + file_size: number; + thumbnail_path: string | null; + duplicate_of: number | null; + metadata: Record | null; +} + +export interface LibraryStats { + total_files: number; + total_folders: number; + total_size_bytes: number; + files_by_type: Record; + total_prints: number; + disk_free_bytes: number; + disk_total_bytes: number; + disk_used_bytes: number; +} + +// Library Queue types +export interface AddToQueueResult { + file_id: number; + filename: string; + queue_item_id: number; + archive_id: number; +} + +export interface AddToQueueError { + file_id: number; + filename: string; + error: string; +} + +export interface AddToQueueResponse { + added: AddToQueueResult[]; + errors: AddToQueueError[]; +} + // Discovery types export interface DiscoveredPrinter { serial: string; diff --git a/frontend/src/components/EditQueueItemModal.tsx b/frontend/src/components/EditQueueItemModal.tsx index 0191e5807..d03fe68b9 100644 --- a/frontend/src/components/EditQueueItemModal.tsx +++ b/frontend/src/components/EditQueueItemModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react'; +import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand, Check, AlertTriangle, Circle, RefreshCw, ChevronDown, ChevronUp, Layers, Settings } from 'lucide-react'; import { api } from '../api/client'; import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client'; import { Card, CardContent } from './Card'; @@ -18,6 +18,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { const { showToast } = useToast(); const [printerId, setPrinterId] = useState(item.printer_id); + const [selectedPlate, setSelectedPlate] = useState(item.plate_id); // Check if scheduled_time is a "placeholder" far-future date (more than 6 months out) const isPlaceholderDate = item.scheduled_time && @@ -39,7 +40,17 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success); const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after); const [showFilamentMapping, setShowFilamentMapping] = useState(false); + const [showPrintOptions, setShowPrintOptions] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); + // Print options + const [printOptions, setPrintOptions] = useState({ + bed_levelling: item.bed_levelling ?? true, + flow_cali: item.flow_cali ?? false, + vibration_cali: item.vibration_cali ?? true, + layer_inspect: item.layer_inspect ?? false, + timelapse: item.timelapse ?? false, + use_ams: item.use_ams ?? true, + }); // Manual slot overrides: slot_id (1-indexed) -> globalTrayId // Initialize from existing ams_mapping if present const [manualMappings, setManualMappings] = useState>(() => { @@ -60,10 +71,27 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { queryFn: () => api.getPrinters(), }); - // Fetch filament requirements from the archived 3MF + // Fetch available plates from the archived 3MF + const { data: platesData } = useQuery({ + queryKey: ['archive-plates', item.archive_id], + queryFn: () => api.getArchivePlates(item.archive_id), + }); + + // Auto-select the first plate for single-plate files, or use existing plate_id + useEffect(() => { + if (platesData?.plates?.length === 1 && !selectedPlate) { + setSelectedPlate(platesData.plates[0].index); + } + }, [platesData, selectedPlate]); + + const isMultiPlate = platesData?.is_multi_plate ?? false; + const plates = platesData?.plates ?? []; + + // Fetch filament requirements from the archived 3MF (filtered by plate if selected) const { data: filamentReqs } = useQuery({ - queryKey: ['archive-filaments', item.archive_id], - queryFn: () => api.getArchiveFilamentRequirements(item.archive_id), + queryKey: ['archive-filaments', item.archive_id, selectedPlate], + queryFn: () => api.getArchiveFilamentRequirements(item.archive_id, selectedPlate ?? undefined), + enabled: selectedPlate !== null || !isMultiPlate, }); // Fetch printer status when a printer is selected @@ -73,13 +101,14 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { enabled: printerId !== null, }); - // Clear manual mappings when printer changes (but not on initial load) + // Clear manual mappings when printer or plate changes (but not on initial load) const [initialPrinterId] = useState(item.printer_id); + const [initialPlateId] = useState(item.plate_id); useEffect(() => { - if (printerId !== initialPrinterId) { + if (printerId !== initialPrinterId || selectedPlate !== initialPlateId) { setManualMappings({}); } - }, [printerId, initialPrinterId]); + }, [printerId, initialPrinterId, selectedPlate, initialPlateId]); // Close on Escape key useEffect(() => { @@ -312,6 +341,8 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { auto_off_after: autoOffAfter, manual_start: scheduleType === 'manual', ams_mapping: amsMapping, + plate_id: selectedPlate, + ...printOptions, }; if (scheduleType === 'scheduled' && scheduledTime) { @@ -393,8 +424,61 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) { )} + {/* Plate selection - show when multi-plate file detected */} + {isMultiPlate && plates.length > 1 && ( +
+
+ + + {!selectedPlate && ( + + + Selection required + + )} +
+
+ {plates.map((plate) => ( + + ))} +
+
+ )} + {/* Filament Mapping Section */} - {printerId !== null && hasFilamentReqs && ( + {printerId !== null && (isMultiPlate ? selectedPlate !== null : true) && hasFilamentReqs && (
+ {showPrintOptions && ( +
+ {[ + { key: 'bed_levelling', label: 'Bed Levelling', desc: 'Auto-level bed before print' }, + { key: 'flow_cali', label: 'Flow Calibration', desc: 'Calibrate extrusion flow' }, + { key: 'vibration_cali', label: 'Vibration Calibration', desc: 'Reduce ringing artifacts' }, + { key: 'layer_inspect', label: 'First Layer Inspection', desc: 'AI inspection of first layer' }, + { key: 'timelapse', label: 'Timelapse', desc: 'Record timelapse video' }, + ].map(({ key, label, desc }) => ( +
+ {/* Schedule type */}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ff76eb9fa..d05f90d6c 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } 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, FolderKanban, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; @@ -25,6 +25,7 @@ export const defaultNavItems: NavItem[] = [ { 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: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' }, { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index fcadfee43..872b9f744 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -70,7 +70,7 @@ export function useWebSocket() { let pingInterval: number | null = null; ws.onopen = () => { - console.log('[WebSocket] Connected'); + if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Connected'); setIsConnected(true); // Start ping interval pingInterval = window.setInterval(() => { @@ -98,7 +98,7 @@ export function useWebSocket() { }; ws.onclose = (event) => { - console.log('[WebSocket] Closed', event.code, event.reason); + if (import.meta.env.MODE !== 'test') console.log('[WebSocket] Closed', event.code, event.reason); if (pingInterval) { clearInterval(pingInterval); pingInterval = null; @@ -113,7 +113,7 @@ export function useWebSocket() { }; ws.onerror = (error) => { - console.error('[WebSocket] Error', error); + if (import.meta.env.MODE !== 'test') console.error('[WebSocket] Error', error); ws.close(); }; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index abcc3ed8a..8f1464c51 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -8,6 +8,7 @@ export default { profiles: 'Profile', maintenance: 'Wartung', projects: 'Projekte', + files: 'Dateimanager', settings: 'Einstellungen', system: 'System', collapseSidebar: 'Seitenleiste einklappen', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 8e4739269..2bdc2c962 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -8,6 +8,7 @@ export default { profiles: 'Profiles', maintenance: 'Maintenance', projects: 'Projects', + files: 'File Manager', settings: 'Settings', system: 'System', collapseSidebar: 'Collapse sidebar', diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 452de36eb..45f3395a8 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Download, @@ -222,6 +223,12 @@ function ArchiveCard({ }, }); + // Query for linked folders + const { data: linkedFolders } = useQuery({ + queryKey: ['archive-folders', archive.id], + queryFn: () => api.getLibraryFoldersByArchive(archive.id), + }); + const assignProjectMutation = useMutation({ mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }), onSuccess: () => { @@ -601,6 +608,18 @@ function ArchiveCard({ )} )} + {/* Linked folder badge */} + {linkedFolders && linkedFolders.length > 0 && ( + e.stopPropagation()} + title={`Open folder: ${linkedFolders[0].name}`} + style={{ left: archive.source_3mf_path ? (archive.f3d_path ? '5.5rem' : '3rem') : (archive.f3d_path ? '3rem' : '0.5rem') }} + > + + + )}
@@ -1197,6 +1216,12 @@ function ArchiveListRow({ }, }); + // Query for linked folders + const { data: linkedFolders } = useQuery({ + queryKey: ['archive-folders', archive.id], + queryFn: () => api.getLibraryFoldersByArchive(archive.id), + }); + const assignProjectMutation = useMutation({ mutationFn: (projectId: number | null) => api.updateArchive(archive.id, { project_id: projectId }), onSuccess: () => { @@ -1471,6 +1496,16 @@ function ArchiveListRow({ )} + {linkedFolders && linkedFolders.length > 0 && ( + e.stopPropagation()} + > + + + )}
{archive.filament_type && (
diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx new file mode 100644 index 000000000..96c1dec86 --- /dev/null +++ b/frontend/src/pages/FileManagerPage.tsx @@ -0,0 +1,1600 @@ +import { useState, useRef, useCallback, useMemo, useEffect, type DragEvent } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FolderOpen, + Loader2, + Plus, + Upload, + Trash2, + Download, + MoreVertical, + ChevronRight, + FolderPlus, + FileBox, + Clock, + HardDrive, + Copy, + File, + MoveRight, + CheckSquare, + Square, + LayoutGrid, + List, + Search, + SortAsc, + SortDesc, + AlertTriangle, + Filter, + X, + CheckCircle, + XCircle, + Link2, + Unlink, + Archive as ArchiveIcon, + Briefcase, + Printer, +} from 'lucide-react'; +import { api } from '../api/client'; +import type { + LibraryFolderTree, + LibraryFileListItem, + LibraryFolderCreate, + LibraryFolderUpdate, + AppSettings, + Archive, +} from '../api/client'; +import { Button } from '../components/Button'; +import { ConfirmModal } from '../components/ConfirmModal'; +import { useToast } from '../contexts/ToastContext'; + +type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; +type SortDirection = 'asc' | 'desc'; + +// Utility to format file size +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +// Utility to format duration +function formatDuration(seconds: number | null): string { + if (!seconds) return '-'; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +// New Folder Modal +interface NewFolderModalProps { + parentId: number | null; + onClose: () => void; + onSave: (data: LibraryFolderCreate) => void; + isLoading: boolean; +} + +function NewFolderModal({ parentId, onClose, onSave, isLoading }: NewFolderModalProps) { + const [name, setName] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave({ name: name.trim(), parent_id: parentId }); + }; + + return ( +
+
+
+

New Folder

+
+
+
+ + setName(e.target.value)} + className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" + placeholder="e.g., Functional Parts" + autoFocus + required + /> +
+
+ + +
+
+
+
+ ); +} + +// Move Files Modal +interface MoveFilesModalProps { + folders: LibraryFolderTree[]; + selectedFiles: number[]; + currentFolderId: number | null; + onClose: () => void; + onMove: (folderId: number | null) => void; + isLoading: boolean; +} + +function MoveFilesModal({ folders, selectedFiles, currentFolderId, onClose, onMove, isLoading }: MoveFilesModalProps) { + const [targetFolder, setTargetFolder] = useState(null); + + const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number | null; name: string; depth: number }[] => { + const result: { id: number | null; name: string; depth: number }[] = []; + for (const item of items) { + result.push({ id: item.id, name: item.name, depth }); + if (item.children.length > 0) { + result.push(...flattenFolders(item.children, depth + 1)); + } + } + return result; + }; + + const flatFolders = [{ id: null, name: 'Root (No Folder)', depth: 0 }, ...flattenFolders(folders)]; + + return ( +
+
+
+

Move {selectedFiles.length} File(s)

+
+
+
+ {flatFolders.map((folder) => ( + + ))} +
+
+ + +
+
+
+
+ ); +} + +// Link Folder Modal +interface LinkFolderModalProps { + folder: LibraryFolderTree; + onClose: () => void; + onLink: (update: LibraryFolderUpdate) => void; + isLoading: boolean; +} + +function LinkFolderModal({ folder, onClose, onLink, isLoading }: LinkFolderModalProps) { + const [linkType, setLinkType] = useState<'project' | 'archive'>('project'); + const [selectedId, setSelectedId] = useState( + folder.project_id || folder.archive_id || null + ); + + // Initialize linkType based on existing link + useState(() => { + if (folder.archive_id) setLinkType('archive'); + }); + + const { data: projects } = useQuery({ + queryKey: ['projects'], + queryFn: () => api.getProjects(), + }); + + const { data: archives } = useQuery({ + queryKey: ['archives-for-link'], + queryFn: () => api.getArchives(undefined, undefined, 100), + }); + + const handleSave = () => { + if (linkType === 'project') { + onLink({ + project_id: selectedId, + archive_id: 0, // Unlink archive + }); + } else { + onLink({ + project_id: 0, // Unlink project + archive_id: selectedId, + }); + } + }; + + const handleUnlink = () => { + onLink({ + project_id: 0, + archive_id: 0, + }); + }; + + const isLinked = folder.project_id || folder.archive_id; + + return ( +
+
+
+

+ + Link Folder +

+ +
+ +
+

+ Link "{folder.name}" to a project or archive for quick access. +

+ + {/* Link type selector */} +
+ + +
+ + {/* Selection list */} +
+ {linkType === 'project' ? ( + projects && projects.length > 0 ? ( + projects.map((project) => ( + + )) + ) : ( +

No projects found

+ ) + ) : ( + archives && archives.length > 0 ? ( + archives.map((archive: Archive) => ( + + )) + ) : ( +

No archives found

+ ) + )} +
+
+ +
+ {isLinked && ( + + )} +
+ + +
+
+
+
+ ); +} + +// Upload Modal with Drag & Drop +interface UploadModalProps { + folderId: number | null; + onClose: () => void; + onUploadComplete: () => void; +} + +interface UploadFile { + file: File; + status: 'pending' | 'uploading' | 'success' | 'error'; + error?: string; +} + +function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) { + const [files, setFiles] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const droppedFiles = Array.from(e.dataTransfer.files); + addFiles(droppedFiles); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + addFiles(Array.from(e.target.files)); + } + }; + + const addFiles = (newFiles: File[]) => { + const uploadFiles: UploadFile[] = newFiles.map((file) => ({ + file, + status: 'pending', + })); + setFiles((prev) => [...prev, ...uploadFiles]); + }; + + const removeFile = (index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const handleUpload = async () => { + if (files.length === 0) return; + + setIsUploading(true); + + for (let i = 0; i < files.length; i++) { + if (files[i].status !== 'pending') continue; + + setFiles((prev) => + prev.map((f, idx) => (idx === i ? { ...f, status: 'uploading' } : f)) + ); + + try { + await api.uploadLibraryFile(files[i].file, folderId); + setFiles((prev) => + prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f)) + ); + } catch (err) { + setFiles((prev) => + prev.map((f, idx) => + idx === i + ? { ...f, status: 'error', error: err instanceof Error ? err.message : 'Upload failed' } + : f + ) + ); + } + } + + setIsUploading(false); + onUploadComplete(); + // Auto-close modal after upload completes + onClose(); + }; + + const pendingCount = files.filter((f) => f.status === 'pending').length; + const successCount = files.filter((f) => f.status === 'success').length; + const errorCount = files.filter((f) => f.status === 'error').length; + const allDone = files.length > 0 && pendingCount === 0 && !isUploading; + + return ( +
+
+
+

Upload Files

+ +
+ +
+ {/* Drop Zone */} +
fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${ + isDragging + ? 'border-bambu-green bg-bambu-green/10' + : 'border-bambu-dark-tertiary hover:border-bambu-green/50' + }`} + > + +

+ {isDragging ? 'Drop files here' : 'Drag & drop files here'} +

+

or click to browse

+
+ + + + {/* File List */} + {files.length > 0 && ( +
+ {files.map((uploadFile, index) => ( +
+ +
+

{uploadFile.file.name}

+

+ {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB +

+
+ {uploadFile.status === 'pending' && ( + + )} + {uploadFile.status === 'uploading' && ( + + )} + {uploadFile.status === 'success' && ( + + )} + {uploadFile.status === 'error' && ( + + + + )} +
+ ))} +
+ )} + + {/* Summary */} + {allDone && ( +
+

+ Upload complete: {successCount} succeeded + {errorCount > 0 && , {errorCount} failed} +

+
+ )} +
+ +
+ + {!allDone && ( + + )} +
+
+
+ ); +} + +// Folder Tree Item +interface FolderTreeItemProps { + folder: LibraryFolderTree; + selectedFolderId: number | null; + onSelect: (id: number | null) => void; + onDelete: (id: number) => void; + onLink: (folder: LibraryFolderTree) => void; + depth?: number; +} + +function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, depth = 0 }: FolderTreeItemProps) { + const [expanded, setExpanded] = useState(true); + const [showActions, setShowActions] = useState(false); + const hasChildren = folder.children.length > 0; + const isLinked = folder.project_id || folder.archive_id; + + return ( +
+
onSelect(folder.id)} + > + {hasChildren ? ( + + ) : ( +
+ )} + + {folder.name} + {/* Link indicator - clickable to change link */} + {isLinked && ( + + )} + {folder.file_count > 0 && ( + {folder.file_count} + )} + {/* Quick link button - always visible for unlinked folders */} + {!isLinked && ( + + )} +
e.stopPropagation()}> +
+ + {showActions && ( + <> +
setShowActions(false)} /> +
+ + +
+ + )} +
+
+
+ {hasChildren && expanded && ( +
+ {folder.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +// Helper to check if a file is sliced (printable) +function isSlicedFilename(filename: string): boolean { + const lower = filename.toLowerCase(); + return lower.endsWith('.gcode') || lower.includes('.gcode.'); +} + +// File Card +interface FileCardProps { + file: LibraryFileListItem; + isSelected: boolean; + onSelect: (id: number) => void; + onDelete: (id: number) => void; + onDownload: (id: number) => void; + onAddToQueue?: (id: number) => void; +} + +function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue }: FileCardProps) { + const [showActions, setShowActions] = useState(false); + + return ( +
onSelect(file.id)} + > + {/* Thumbnail */} +
+ {file.thumbnail_path ? ( + {file.filename} + ) : ( + + )} + {/* Duplicate badge */} + {file.duplicate_count > 0 && ( +
+ + {file.duplicate_count} +
+ )} + {/* File type badge */} +
+ {file.file_type.toUpperCase()} +
+
+ + {/* Info */} +
+

+ {file.print_name || file.filename} +

+
+ {formatFileSize(file.file_size)} + {file.print_time_seconds && ( + + + {formatDuration(file.print_time_seconds)} + + )} +
+ {file.print_count > 0 && ( +
+ Printed {file.print_count}x +
+ )} +
+ + {/* Actions */} +
e.stopPropagation()}> + + {showActions && ( + <> +
setShowActions(false)} /> +
+ {onAddToQueue && isSlicedFilename(file.filename) && ( + + )} + + +
+ + )} +
+ + {/* Selection checkbox */} +
+ {isSelected &&
} +
+
+ ); +} + +export function FileManagerPage() { + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const [searchParams] = useSearchParams(); + + // Read folder ID from URL query parameter + const folderIdFromUrl = searchParams.get('folder'); + const initialFolderId = folderIdFromUrl ? parseInt(folderIdFromUrl, 10) : null; + + // State + const [selectedFolderId, setSelectedFolderId] = useState(initialFolderId); + const [selectedFiles, setSelectedFiles] = useState([]); + const [showNewFolderModal, setShowNewFolderModal] = useState(false); + const [showMoveModal, setShowMoveModal] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + const [linkFolder, setLinkFolder] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null); + const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => { + return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid'; + }); + + // Filter and sort state + const [searchQuery, setSearchQuery] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [sortField, setSortField] = useState('date'); + const [sortDirection, setSortDirection] = useState('desc'); + + // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page) + useEffect(() => { + const folderParam = searchParams.get('folder'); + const newFolderId = folderParam ? parseInt(folderParam, 10) : null; + if (newFolderId !== selectedFolderId) { + setSelectedFolderId(newFolderId); + } + }, [searchParams]); + + // Queries + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: () => api.getSettings() as Promise, + }); + const { data: folders, isLoading: foldersLoading } = useQuery({ + queryKey: ['library-folders'], + queryFn: () => api.getLibraryFolders(), + }); + + const { data: files, isLoading: filesLoading } = useQuery({ + queryKey: ['library-files', selectedFolderId], + queryFn: () => api.getLibraryFiles(selectedFolderId, selectedFolderId === null), + }); + + const { data: stats } = useQuery({ + queryKey: ['library-stats'], + queryFn: () => api.getLibraryStats(), + }); + + // Get unique file types for filter dropdown + const fileTypes = useMemo(() => { + if (!files) return []; + const types = new Set(files.map((f) => f.file_type)); + return Array.from(types).sort(); + }, [files]); + + // Filter and sort files + const filteredAndSortedFiles = useMemo(() => { + if (!files) return []; + + let result = [...files]; + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (f) => + f.filename.toLowerCase().includes(query) || + (f.print_name && f.print_name.toLowerCase().includes(query)) + ); + } + + // Apply type filter + if (filterType !== 'all') { + result = result.filter((f) => f.file_type === filterType); + } + + // Apply sorting + result.sort((a, b) => { + let comparison = 0; + switch (sortField) { + case 'name': + comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename); + break; + case 'date': + comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + break; + case 'size': + comparison = a.file_size - b.file_size; + break; + case 'type': + comparison = a.file_type.localeCompare(b.file_type); + break; + case 'prints': + comparison = a.print_count - b.print_count; + break; + } + return sortDirection === 'asc' ? comparison : -comparison; + }); + + return result; + }, [files, searchQuery, filterType, sortField, sortDirection]); + + // Check if disk space is low + const isDiskSpaceLow = useMemo(() => { + if (!stats || !settings) return false; + const thresholdBytes = (settings.library_disk_warning_gb || 5) * 1024 * 1024 * 1024; + return stats.disk_free_bytes < thresholdBytes; + }, [stats, settings]); + + // Mutations + const createFolderMutation = useMutation({ + mutationFn: (data: LibraryFolderCreate) => api.createLibraryFolder(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['library-folders'] }); + setShowNewFolderModal(false); + showToast('Folder created', 'success'); + }, + onError: (error: Error) => showToast(error.message, 'error'), + }); + + const deleteFolderMutation = useMutation({ + mutationFn: (id: number) => api.deleteLibraryFolder(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['library-folders'] }); + queryClient.invalidateQueries({ queryKey: ['library-files'] }); + queryClient.invalidateQueries({ queryKey: ['library-stats'] }); + if (selectedFolderId === deleteConfirm?.id) { + setSelectedFolderId(null); + } + setDeleteConfirm(null); + showToast('Folder deleted', 'success'); + }, + onError: (error: Error) => { + setDeleteConfirm(null); + showToast(error.message, 'error'); + }, + }); + + const deleteFileMutation = useMutation({ + mutationFn: (id: number) => api.deleteLibraryFile(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['library-files'] }); + queryClient.invalidateQueries({ queryKey: ['library-folders'] }); + queryClient.invalidateQueries({ queryKey: ['library-stats'] }); + setSelectedFiles((prev) => prev.filter((id) => id !== deleteConfirm?.id)); + setDeleteConfirm(null); + showToast('File deleted', 'success'); + }, + onError: (error: Error) => { + setDeleteConfirm(null); + showToast(error.message, 'error'); + }, + }); + + const moveFilesMutation = useMutation({ + mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) => + api.moveLibraryFiles(fileIds, folderId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['library-files'] }); + queryClient.invalidateQueries({ queryKey: ['library-folders'] }); + setSelectedFiles([]); + setShowMoveModal(false); + showToast('Files moved', 'success'); + }, + onError: (error: Error) => showToast(error.message, 'error'), + }); + + const updateFolderMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: LibraryFolderUpdate }) => + api.updateLibraryFolder(id, data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['library-folders'] }); + // Invalidate project/archive folder queries so other pages see the update + queryClient.invalidateQueries({ queryKey: ['project-folders'] }); + queryClient.invalidateQueries({ queryKey: ['archive-folders'] }); + setLinkFolder(null); + const isUnlink = variables.data.project_id === 0 && variables.data.archive_id === 0; + showToast(isUnlink ? 'Folder unlinked' : 'Folder linked', 'success'); + }, + onError: (error: Error) => showToast(error.message, 'error'), + }); + + const addToQueueMutation = useMutation({ + mutationFn: (fileIds: number[]) => api.addLibraryFilesToQueue(fileIds), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['library-files'] }); + queryClient.invalidateQueries({ queryKey: ['queue'] }); + setSelectedFiles([]); + + if (result.added.length > 0 && result.errors.length === 0) { + showToast( + `Added ${result.added.length} file${result.added.length > 1 ? 's' : ''} to queue`, + 'success' + ); + } else if (result.added.length > 0 && result.errors.length > 0) { + showToast( + `Added ${result.added.length} file${result.added.length > 1 ? 's' : ''}, ${result.errors.length} failed`, + 'success' + ); + } else { + showToast(`Failed to add files: ${result.errors[0]?.error || 'Unknown error'}`, 'error'); + } + }, + onError: (error: Error) => showToast(error.message, 'error'), + }); + + // Helper to check if a file is sliced (printable) + const isSlicedFile = useCallback((filename: string) => { + const lower = filename.toLowerCase(); + return lower.endsWith('.gcode') || lower.includes('.gcode.'); + }, []); + + // Get sliced files from selection + const selectedSlicedFiles = useMemo(() => { + if (!files) return []; + return files.filter(f => selectedFiles.includes(f.id) && isSlicedFile(f.filename)); + }, [files, selectedFiles, isSlicedFile]); + + // Handlers + const handleFileSelect = useCallback((id: number) => { + // Always toggle selection (multi-select by default) + setSelectedFiles((prev) => { + return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]; + }); + }, []); + + const handleSelectAll = useCallback(() => { + if (filteredAndSortedFiles.length > 0) { + setSelectedFiles(filteredAndSortedFiles.map((f) => f.id)); + } + }, [filteredAndSortedFiles]); + + const handleDeselectAll = useCallback(() => { + setSelectedFiles([]); + }, []); + + const handleUploadComplete = () => { + queryClient.invalidateQueries({ queryKey: ['library-files'] }); + queryClient.invalidateQueries({ queryKey: ['library-folders'] }); + queryClient.invalidateQueries({ queryKey: ['library-stats'] }); + }; + + const handleDownload = (id: number) => { + window.open(api.getLibraryFileDownloadUrl(id), '_blank'); + }; + + const handleDeleteConfirm = () => { + if (!deleteConfirm) return; + if (deleteConfirm.type === 'file') { + deleteFileMutation.mutate(deleteConfirm.id); + } else if (deleteConfirm.type === 'folder') { + deleteFolderMutation.mutate(deleteConfirm.id); + } else if (deleteConfirm.type === 'bulk') { + // Bulk delete selected files + api.bulkDeleteLibrary(selectedFiles, []).then(() => { + queryClient.invalidateQueries({ queryKey: ['library-files'] }); + queryClient.invalidateQueries({ queryKey: ['library-folders'] }); + queryClient.invalidateQueries({ queryKey: ['library-stats'] }); + showToast(`Deleted ${selectedFiles.length} files`, 'success'); + setSelectedFiles([]); + setDeleteConfirm(null); + }).catch((err) => { + showToast(err.message, 'error'); + setDeleteConfirm(null); + }); + } + }; + + const handleViewModeChange = (mode: 'grid' | 'list') => { + setViewMode(mode); + localStorage.setItem('library-view-mode', mode); + }; + + const isLoading = foldersLoading || filesLoading; + + return ( +
+ {/* Header */} +
+
+

+
+ +
+ File Manager +

+

+ Organize and manage your print files +

+
+
+ {/* View mode toggle */} +
+ + +
+ + +
+
+ + {/* Disk space warning */} + {isDiskSpaceLow && stats && settings && ( +
+ +
+

Low disk space warning

+

+ Only {formatFileSize(stats.disk_free_bytes)} free of {formatFileSize(stats.disk_total_bytes)} total. + Threshold is set to {settings.library_disk_warning_gb} GB in settings. +

+
+
+ )} + + {/* Stats bar */} + {stats && ( +
+
+ + Files: + {stats.total_files} +
+
+ + Folders: + {stats.total_folders} +
+
+ + Size: + {formatFileSize(stats.total_size_bytes)} +
+
+ Free: + + {formatFileSize(stats.disk_free_bytes)} + +
+
+ )} + + {/* Main content */} +
+ {/* Folder sidebar */} +
+
+

Folders

+
+
+ {/* All Files (root) */} +
setSelectedFolderId(null)} + > + + All Files +
+ + {/* Folder tree */} + {folders?.map((folder) => ( + setDeleteConfirm({ type: 'folder', id })} + onLink={setLinkFolder} + /> + ))} +
+
+ + {/* Files area */} +
+ {/* Search, Filter, Sort toolbar */} + {files && files.length > 0 && ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green" + /> +
+ + {/* Type filter */} +
+ + +
+ + {/* Sort */} +
+ + +
+ + {/* Results count */} + {(searchQuery || filterType !== 'all') && ( + + {filteredAndSortedFiles.length} of {files.length} files + + )} +
+ )} + + {/* Selection toolbar */} + {filteredAndSortedFiles.length > 0 && ( +
+ {/* Select all / Deselect all */} + {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? ( + + ) : ( + + )} + + {selectedFiles.length > 0 && ( + <> + + {selectedFiles.length} selected + +
+ {selectedSlicedFiles.length > 0 && ( + + )} + + + + + )} +
+ )} + + {/* File grid/list */} + {isLoading ? ( +
+
+ +

Loading files...

+
+
+ ) : files?.length === 0 ? ( +
+
+ +
+

+ {selectedFolderId !== null ? 'Folder is empty' : 'No files yet'} +

+

+ {selectedFolderId !== null + ? 'Upload files or move files into this folder to get started.' + : 'Upload files to start organizing your print-related files.'} +

+ +
+ ) : filteredAndSortedFiles.length === 0 ? ( +
+
+ +
+

No matching files

+

+ No files match your current search or filter criteria. +

+ +
+ ) : viewMode === 'grid' ? ( +
+
+ {filteredAndSortedFiles.map((file) => ( + setDeleteConfirm({ type: 'file', id })} + onDownload={handleDownload} + onAddToQueue={(id) => addToQueueMutation.mutate([id])} + /> + ))} +
+
+ ) : ( +
+
+ {/* List header */} +
+
+
Name
+
Type
+
Size
+
Prints
+
+
+ {/* List rows */} + {filteredAndSortedFiles.map((file) => ( +
handleFileSelect(file.id)} + > + {/* Checkbox */} +
+ {selectedFiles.includes(file.id) &&
} +
+ {/* Name with thumbnail */} +
+
+
+ {file.thumbnail_path ? ( + + ) : ( +
+ +
+ )} +
+ {/* Hover preview */} + {file.thumbnail_path && ( +
+
+ {file.filename} +
+
+ )} +
+
+
{file.print_name || file.filename}
+ {file.duplicate_count > 0 && ( +
+ + {file.duplicate_count} duplicate(s) +
+ )} +
+
+ {/* Type */} +
+ + {file.file_type.toUpperCase()} + +
+ {/* Size */} +
{formatFileSize(file.file_size)}
+ {/* Prints */} +
{file.print_count > 0 ? `${file.print_count}x` : '-'}
+ {/* Actions */} +
e.stopPropagation()}> + {isSlicedFilename(file.filename) && ( + + )} + + +
+
+ ))} +
+
+ )} +
+
+ + {/* Modals */} + {showNewFolderModal && ( + setShowNewFolderModal(false)} + onSave={(data) => createFolderMutation.mutate(data)} + isLoading={createFolderMutation.isPending} + /> + )} + + {showMoveModal && folders && ( + setShowMoveModal(false)} + onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })} + isLoading={moveFilesMutation.isPending} + /> + )} + + {showUploadModal && ( + setShowUploadModal(false)} + onUploadComplete={handleUploadComplete} + /> + )} + + {linkFolder && ( + setLinkFolder(null)} + onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })} + isLoading={updateFolderMutation.isPending} + /> + )} + + {deleteConfirm && ( + setDeleteConfirm(null)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index c4264d6e4..fc990e33a 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { @@ -18,11 +18,7 @@ import { AlertTriangle, Save, X, - Paperclip, - Upload, - Download, Trash2, - File, Plus, History, FolderTree, @@ -30,6 +26,7 @@ import { Layers, ExternalLink, ShoppingCart, + FolderOpen, } from 'lucide-react'; import { api } from '../api/client'; import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date'; @@ -202,8 +199,6 @@ export function ProjectDetailPage() { const [showEditModal, setShowEditModal] = useState(false); const [editingNotes, setEditingNotes] = useState(false); const [notesContent, setNotesContent] = useState(''); - const [uploadingAttachment, setUploadingAttachment] = useState(false); - const fileInputRef = useRef(null); const projectId = parseInt(id || '0', 10); @@ -236,6 +231,12 @@ export function ProjectDetailPage() { queryFn: api.getSettings, }); + const { data: linkedFolders } = useQuery({ + queryKey: ['project-folders', projectId], + queryFn: () => api.getLibraryFoldersByProject(projectId), + enabled: projectId > 0, + }); + const currency = settings?.currency || '$'; const timeFormat: TimeFormat = settings?.time_format || 'system'; @@ -267,49 +268,6 @@ export function ProjectDetailPage() { setNotesContent(''); }; - const handleFileSelect = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - setUploadingAttachment(true); - try { - const result = await api.uploadProjectAttachment(projectId, file); - queryClient.invalidateQueries({ queryKey: ['project', projectId] }); - showToast(`Uploaded: ${result.original_name}`, 'success'); - } catch (error) { - showToast((error as Error).message, 'error'); - } finally { - setUploadingAttachment(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } - }; - - const handleDeleteAttachment = (filename: string, originalName: string) => { - setConfirmModal({ - isOpen: true, - title: 'Delete Attachment', - message: `Are you sure you want to delete "${originalName}"?`, - onConfirm: async () => { - setConfirmModal(prev => ({ ...prev, isOpen: false })); - try { - await api.deleteProjectAttachment(projectId, filename); - queryClient.invalidateQueries({ queryKey: ['project', projectId] }); - showToast('Attachment deleted', 'success'); - } catch (error) { - showToast((error as Error).message, 'error'); - } - }, - }); - }; - - const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - }; - // BOM handlers const [newBomName, setNewBomName] = useState(''); const [newBomQty, setNewBomQty] = useState(1); @@ -787,82 +745,49 @@ export function ProjectDetailPage() { - {/* Attachments section */} + {/* Files section - linked folders from File Manager */}

- - Attachments ({project.attachments?.length || 0}) + + Files

-
- - -

- Upload any file: images (PNG, JPG), PDFs, STL files, or documents. + + Link folders from the File Manager + + {' '}to this project for quick access.

- {project.attachments && project.attachments.length > 0 ? ( + {linkedFolders && linkedFolders.length > 0 ? (
- {project.attachments.map((attachment) => ( -
( +
- +

- {attachment.original_name} + {folder.name}

- {formatFileSize(attachment.size)} + {folder.file_count} file{folder.file_count !== 1 ? 's' : ''}

-
- - - - -
-
+ + ))}
) : (

- No attachments yet. Click Upload to add files. + No folders linked. Go to File Manager and link a folder to this project.

)}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 961ca6afa..ac22b038b 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -322,6 +322,8 @@ export function SettingsPage() { mutationFn: api.updateSettings, onSuccess: (data) => { queryClient.setQueryData(['settings'], data); + // Sync localSettings with the saved data to prevent re-triggering saves + setLocalSettings(data); // Invalidate archive stats to reflect energy tracking mode change queryClient.invalidateQueries({ queryKey: ['archiveStats'] }); showToast('Settings saved', 'success'); @@ -375,7 +377,9 @@ export function SettingsPage() { settings.mqtt_use_tls !== localSettings.mqtt_use_tls || settings.ha_enabled !== localSettings.ha_enabled || settings.ha_url !== localSettings.ha_url || - settings.ha_token !== localSettings.ha_token; + settings.ha_token !== localSettings.ha_token || + (settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') || + Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5); if (!hasChanges) { return; @@ -432,6 +436,8 @@ export function SettingsPage() { ha_enabled: localSettings.ha_enabled, ha_url: localSettings.ha_url, ha_token: localSettings.ha_token, + library_archive_mode: localSettings.library_archive_mode, + library_disk_warning_gb: localSettings.library_disk_warning_gb, }; updateMutation.mutate(settingsToSave); }, 500); @@ -943,12 +949,63 @@ export function SettingsPage() {
- {/* Sidebar Links */} - + {/* File Manager Settings */} + + +

+ + File Manager +

+
+ + {/* Archive Mode */} +
+ + +

+ When printing from File Manager, optionally create an archive entry +

+
+ + {/* Disk Space Warning Threshold */} +
+ +
+ updateSetting('library_disk_warning_gb', parseFloat(e.target.value) || 5)} + className="w-24 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> + GB +
+

+ Show warning when free disk space falls below this threshold +

+
+
+
- {/* Right Column - Updates */} + {/* Third Column - Sidebar Links & Updates */}
+ {/* Sidebar Links */} + diff --git a/frontend/src/pages/SystemInfoPage.tsx b/frontend/src/pages/SystemInfoPage.tsx index b3598bc55..e2f08c7c8 100644 --- a/frontend/src/pages/SystemInfoPage.tsx +++ b/frontend/src/pages/SystemInfoPage.tsx @@ -20,11 +20,19 @@ import { Bug, Download, Headphones, + FolderOpen, } from 'lucide-react'; import { api, supportApi } from '../api/client'; import { Card } from '../components/Card'; import { formatDateTime, type TimeFormat } from '../utils/date'; +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + function StatCard({ icon: Icon, label, @@ -108,6 +116,11 @@ export function SystemInfoPage() { queryFn: api.getSettings, }); + const { data: libraryStats } = useQuery({ + queryKey: ['library-stats'], + queryFn: api.getLibraryStats, + }); + const timeFormat: TimeFormat = settings?.time_format || 'system'; const handleToggleDebugLogging = async () => { @@ -456,7 +469,7 @@ export function SystemInfoPage() { {(100 - systemInfo.storage.disk_percent_used).toFixed(1)}%)

-
+
+ {libraryStats && ( + + )}
diff --git a/static/assets/index-BNfnoADT.js b/static/assets/index-BNfnoADT.js deleted file mode 100644 index ddda77a44..000000000 --- a/static/assets/index-BNfnoADT.js +++ /dev/null @@ -1,8663 +0,0 @@ -function Sie(t,e){for(var n=0;nr[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 a of i)if(a.type==="childList")for(const o of a.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const a={};return i.integrity&&(a.integrity=i.integrity),i.referrerPolicy&&(a.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?a.credentials="include":i.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(i){if(i.ep)return;i.ep=!0;const a=n(i);fetch(i.href,a)}})();var Pv=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function wo(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var qN={exports:{}},Mx={};var LF;function Mie(){if(LF)return Mx;LF=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,a){var o=null;if(a!==void 0&&(o=""+a),i.key!==void 0&&(o=""+i.key),"key"in i){a={};for(var l in i)l!=="key"&&(a[l]=i[l])}else a=i;return i=a.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:a}}return Mx.Fragment=e,Mx.jsx=n,Mx.jsxs=n,Mx}var IF;function Eie(){return IF||(IF=1,qN.exports=Mie()),qN.exports}var s=Eie(),KN={exports:{}},sn={};var FF;function Nie(){if(FF)return sn;FF=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"),a=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),g=Symbol.iterator;function p(F){return F===null||typeof F!="object"?null:(F=g&&F[g]||F["@@iterator"],typeof F=="function"?F:null)}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},v=Object.assign,x={};function y(F,G,ee){this.props=F,this.context=G,this.refs=x,this.updater=ee||b}y.prototype.isReactComponent={},y.prototype.setState=function(F,G){if(typeof F!="object"&&typeof F!="function"&&F!=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,F,G,"setState")},y.prototype.forceUpdate=function(F){this.updater.enqueueForceUpdate(this,F,"forceUpdate")};function E(){}E.prototype=y.prototype;function w(F,G,ee){this.props=F,this.context=G,this.refs=x,this.updater=ee||b}var A=w.prototype=new E;A.constructor=w,v(A,y.prototype),A.isPureReactComponent=!0;var N=Array.isArray;function C(){}var T={H:null,A:null,T:null,S:null},O=Object.prototype.hasOwnProperty;function k(F,G,ee){var te=ee.ref;return{$$typeof:t,type:F,key:G,ref:te!==void 0?te:null,props:ee}}function R(F,G){return k(F.type,G,F.props)}function B(F){return typeof F=="object"&&F!==null&&F.$$typeof===t}function I(F){var G={"=":"=0",":":"=2"};return"$"+F.replace(/[=:]/g,function(ee){return G[ee]})}var X=/\/+/g;function D(F,G){return typeof F=="object"&&F!==null&&F.key!=null?I(""+F.key):G.toString(36)}function Y(F){switch(F.status){case"fulfilled":return F.value;case"rejected":throw F.reason;default:switch(typeof F.status=="string"?F.then(C,C):(F.status="pending",F.then(function(G){F.status==="pending"&&(F.status="fulfilled",F.value=G)},function(G){F.status==="pending"&&(F.status="rejected",F.reason=G)})),F.status){case"fulfilled":return F.value;case"rejected":throw F.reason}}throw F}function Q(F,G,ee,te,ie){var ce=typeof F;(ce==="undefined"||ce==="boolean")&&(F=null);var pe=!1;if(F===null)pe=!0;else switch(ce){case"bigint":case"string":case"number":pe=!0;break;case"object":switch(F.$$typeof){case t:case e:pe=!0;break;case d:return pe=F._init,Q(pe(F._payload),G,ee,te,ie)}}if(pe)return ie=ie(F),pe=te===""?"."+D(F,0):te,N(ie)?(ee="",pe!=null&&(ee=pe.replace(X,"$&/")+"/"),Q(ie,G,ee,"",function(Se){return Se})):ie!=null&&(B(ie)&&(ie=R(ie,ee+(ie.key==null||F&&F.key===ie.key?"":(""+ie.key).replace(X,"$&/")+"/")+pe)),G.push(ie)),1;pe=0;var ue=te===""?".":te+":";if(N(F))for(var me=0;me>>1,W=Q[q];if(0>>1;qi(ee,K))tei(ie,ee)?(Q[q]=ie,Q[te]=K,q=te):(Q[q]=ee,Q[G]=K,q=G);else if(tei(ie,K))Q[q]=ie,Q[te]=K,q=te;else break e}}return V}function i(Q,V){var K=Q.sortIndex-V.sortIndex;return K!==0?K:Q.id-V.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var a=performance;t.unstable_now=function(){return a.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],u=[],d=1,f=null,g=3,p=!1,b=!1,v=!1,x=!1,y=typeof setTimeout=="function"?setTimeout:null,E=typeof clearTimeout=="function"?clearTimeout:null,w=typeof setImmediate<"u"?setImmediate:null;function A(Q){for(var V=n(u);V!==null;){if(V.callback===null)r(u);else if(V.startTime<=Q)r(u),V.sortIndex=V.expirationTime,e(c,V);else break;V=n(u)}}function N(Q){if(v=!1,A(Q),!b)if(n(c)!==null)b=!0,C||(C=!0,I());else{var V=n(u);V!==null&&Y(N,V.startTime-Q)}}var C=!1,T=-1,O=5,k=-1;function R(){return x?!0:!(t.unstable_now()-kQ&&R());){var q=f.callback;if(typeof q=="function"){f.callback=null,g=f.priorityLevel;var W=q(f.expirationTime<=Q);if(Q=t.unstable_now(),typeof W=="function"){f.callback=W,A(Q),V=!0;break t}f===n(c)&&r(c),A(Q)}else r(c);f=n(c)}if(f!==null)V=!0;else{var F=n(u);F!==null&&Y(N,F.startTime-Q),V=!1}}break e}finally{f=null,g=K,p=!1}V=void 0}}finally{V?I():C=!1}}}var I;if(typeof w=="function")I=function(){w(B)};else if(typeof MessageChannel<"u"){var X=new MessageChannel,D=X.port2;X.port1.onmessage=B,I=function(){D.postMessage(null)}}else I=function(){y(B,0)};function Y(Q,V){T=y(function(){Q(t.unstable_now())},V)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(Q){Q.callback=null},t.unstable_forceFrameRate=function(Q){0>Q||125q?(Q.sortIndex=K,e(u,Q),n(c)===null&&Q===n(u)&&(v?(E(T),T=-1):v=!0,Y(N,K-q))):(Q.sortIndex=W,e(c,Q),b||p||(b=!0,C||(C=!0,I()))),Q},t.unstable_shouldYield=R,t.unstable_wrapCallback=function(Q){var V=g;return function(){var K=g;g=V;try{return Q.apply(this,arguments)}finally{g=K}}}})(ZN)),ZN}var BF;function Cie(){return BF||(BF=1,YN.exports=Tie()),YN.exports}var QN={exports:{}},ga={};var HF;function kie(){if(HF)return ga;HF=1;var t=ug();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),QN.exports=kie(),QN.exports}var VF;function jie(){if(VF)return Ex;VF=1;var t=Cie(),e=ug(),n=uG();function r(h){var m="https://react.dev/errors/"+h;if(1W||(h.current=q[W],q[W]=null,W--)}function ee(h,m){W++,q[W]=h.current,h.current=m}var te=F(null),ie=F(null),ce=F(null),pe=F(null);function ue(h,m){switch(ee(ce,m),ee(ie,h),ee(te,null),m.nodeType){case 9:case 11:h=(h=m.documentElement)&&(h=h.namespaceURI)?aF(h):0;break;default:if(h=m.tagName,m=m.namespaceURI)m=aF(m),h=sF(m,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}G(te),ee(te,h)}function me(){G(te),G(ie),G(ce)}function Se(h){h.memoizedState!==null&&ee(pe,h);var m=te.current,_=sF(m,h.type);m!==_&&(ee(ie,h),ee(te,_))}function be(h){ie.current===h&&(G(te),G(ie)),pe.current===h&&(G(pe),vx._currentValue=K)}var oe,He;function $(h){if(oe===void 0)try{throw Error()}catch(_){var m=_.stack.trim().match(/\n( *(at )?)/);oe=m&&m[1]||"",He=-1<_.stack.indexOf(` - at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` -`+oe+h+He}var z=!1;function se(h,m){if(!h||z)return"";z=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var M={DetermineComponentFrameRoot:function(){try{if(m){var pt=function(){throw Error()};if(Object.defineProperty(pt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(pt,[])}catch(ot){var it=ot}Reflect.construct(h,[],pt)}else{try{pt.call()}catch(ot){it=ot}h.call(pt.prototype)}}else{try{throw Error()}catch(ot){it=ot}(pt=h())&&typeof pt.catch=="function"&&pt.catch(function(){})}}catch(ot){if(ot&&it&&typeof ot.stack=="string")return[ot.stack,it.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var j=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,"name");j&&j.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var L=M.DetermineComponentFrameRoot(),J=L[0],de=L[1];if(J&&de){var ke=J.split(` -`),tt=de.split(` -`);for(j=M=0;Mj||ke[M]!==tt[j]){var ct=` -`+ke[M].replace(" at new "," at ");return h.displayName&&ct.includes("")&&(ct=ct.replace("",h.displayName)),ct}while(1<=M&&0<=j);break}}}finally{z=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?$(_):""}function U(h,m){switch(h.tag){case 26:case 27:case 5:return $(h.type);case 16:return $("Lazy");case 13:return h.child!==m&&m!==null?$("Suspense Fallback"):$("Suspense");case 19:return $("SuspenseList");case 0:case 15:return se(h.type,!1);case 11:return se(h.type.render,!1);case 1:return se(h.type,!0);case 31:return $("Activity");default:return""}}function Z(h){try{var m="",_=null;do m+=U(h,_),_=h,h=h.return;while(h);return m}catch(M){return` -Error generating stack: `+M.message+` -`+M.stack}}var le=Object.prototype.hasOwnProperty,je=t.unstable_scheduleCallback,Me=t.unstable_cancelCallback,fe=t.unstable_shouldYield,H=t.unstable_requestPaint,P=t.unstable_now,re=t.unstable_getCurrentPriorityLevel,_e=t.unstable_ImmediatePriority,Ee=t.unstable_UserBlockingPriority,we=t.unstable_NormalPriority,rt=t.unstable_LowPriority,We=t.unstable_IdlePriority,at=t.log,dt=t.unstable_setDisableYieldValue,Le=null,he=null;function Ke(h){if(typeof at=="function"&&dt(h),he&&typeof he.setStrictMode=="function")try{he.setStrictMode(Le,h)}catch{}}var Xe=Math.clz32?Math.clz32:ne,Qe=Math.log,lt=Math.LN2;function ne(h){return h>>>=0,h===0?32:31-(Qe(h)/lt|0)|0}var ze=256,ge=262144,Be=4194304;function Pe(h){var m=h&42;if(m!==0)return m;switch(h&-h){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 h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function Ne(h,m,_){var M=h.pendingLanes;if(M===0)return 0;var j=0,L=h.suspendedLanes,J=h.pingedLanes;h=h.warmLanes;var de=M&134217727;return de!==0?(M=de&~L,M!==0?j=Pe(M):(J&=de,J!==0?j=Pe(J):_||(_=de&~h,_!==0&&(j=Pe(_))))):(de=M&~L,de!==0?j=Pe(de):J!==0?j=Pe(J):_||(_=M&~h,_!==0&&(j=Pe(_)))),j===0?0:m!==0&&m!==j&&(m&L)===0&&(L=j&-j,_=m&-m,L>=_||L===32&&(_&4194048)!==0)?m:j}function Ye(h,m){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&m)===0}function ht(h,m){switch(h){case 1:case 2:case 4:case 8:case 64:return m+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 m+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 Nt(){var h=Be;return Be<<=1,(Be&62914560)===0&&(Be=4194304),h}function Dt(h){for(var m=[],_=0;31>_;_++)m.push(h);return m}function Zt(h,m){h.pendingLanes|=m,m!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function jt(h,m,_,M,j,L){var J=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var de=h.entanglements,ke=h.expirationTimes,tt=h.hiddenUpdates;for(_=J&~_;0<_;){var ct=31-Xe(_),pt=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Yr=/[\n"\\]/g;function Vn(h){return h.replace(Yr,function(m){return"\\"+m.charCodeAt(0).toString(16)+" "})}function Mo(h,m,_,M,j,L,J,de){h.name="",J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"?h.type=J:h.removeAttribute("type"),m!=null?J==="number"?(m===0&&h.value===""||h.value!=m)&&(h.value=""+wn(m)):h.value!==""+wn(m)&&(h.value=""+wn(m)):J!=="submit"&&J!=="reset"||h.removeAttribute("value"),m!=null?Li(h,J,wn(m)):_!=null?Li(h,J,wn(_)):M!=null&&h.removeAttribute("value"),j==null&&L!=null&&(h.defaultChecked=!!L),j!=null&&(h.checked=j&&typeof j!="function"&&typeof j!="symbol"),de!=null&&typeof de!="function"&&typeof de!="symbol"&&typeof de!="boolean"?h.name=""+wn(de):h.removeAttribute("name")}function Wn(h,m,_,M,j,L,J,de){if(L!=null&&typeof L!="function"&&typeof L!="symbol"&&typeof L!="boolean"&&(h.type=L),m!=null||_!=null){if(!(L!=="submit"&&L!=="reset"||m!=null)){an(h);return}_=_!=null?""+wn(_):"",m=m!=null?""+wn(m):_,de||m===h.value||(h.value=m),h.defaultValue=m}M=M??j,M=typeof M!="function"&&typeof M!="symbol"&&!!M,h.checked=de?h.checked:!!M,h.defaultChecked=!!M,J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"&&(h.name=J),an(h)}function Li(h,m,_){m==="number"&&zr(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function yi(h,m,_,M){if(h=h.options,m){m={};for(var j=0;j<_.length;j++)m["$"+_[j]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),q2=!1;if(Vl)try{var Fg={};Object.defineProperty(Fg,"passive",{get:function(){q2=!0}}),window.addEventListener("test",Fg,Fg),window.removeEventListener("test",Fg,Fg)}catch{q2=!1}var lu=null,K2=null,My=null;function sL(){if(My)return My;var h,m=K2,_=m.length,M,j="value"in lu?lu.value:lu.textContent,L=j.length;for(h=0;h<_&&m[h]===j[h];h++);var J=_-h;for(M=1;M<=J&&m[_-M]===j[L-M];M++);return My=j.slice(h,1=Bg),hL=" ",fL=!1;function mL(h,m){switch(h){case"keyup":return Gne.indexOf(m.keyCode)!==-1;case"keydown":return m.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function pL(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Uf=!1;function qne(h,m){switch(h){case"compositionend":return pL(m);case"keypress":return m.which!==32?null:(fL=!0,hL);case"textInput":return h=m.data,h===hL&&fL?null:h;default:return null}}function Kne(h,m){if(Uf)return h==="compositionend"||!J2&&mL(h,m)?(h=sL(),My=K2=lu=null,Uf=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(m.ctrlKey||m.altKey||m.metaKey)||m.ctrlKey&&m.altKey){if(m.char&&1=m)return{node:_,offset:m-h};h=M}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=SL(_)}}function EL(h,m){return h&&m?h===m?!0:h&&h.nodeType===3?!1:m&&m.nodeType===3?EL(h,m.parentNode):"contains"in h?h.contains(m):h.compareDocumentPosition?!!(h.compareDocumentPosition(m)&16):!1:!1}function NL(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var m=zr(h.document);m instanceof h.HTMLIFrameElement;){try{var _=typeof m.contentWindow.location.href=="string"}catch{_=!1}if(_)h=m.contentWindow;else break;m=zr(h.document)}return m}function nE(h){var m=h&&h.nodeName&&h.nodeName.toLowerCase();return m&&(m==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||m==="textarea"||h.contentEditable==="true")}var nre=Vl&&"documentMode"in document&&11>=document.documentMode,zf=null,rE=null,Gg=null,iE=!1;function AL(h,m,_){var M=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;iE||zf==null||zf!==zr(M)||(M=zf,"selectionStart"in M&&nE(M)?M={start:M.selectionStart,end:M.selectionEnd}:(M=(M.ownerDocument&&M.ownerDocument.defaultView||window).getSelection(),M={anchorNode:M.anchorNode,anchorOffset:M.anchorOffset,focusNode:M.focusNode,focusOffset:M.focusOffset}),Gg&&Vg(Gg,M)||(Gg=M,M=xv(rE,"onSelect"),0>=J,j-=J,rl=1<<32-Xe(m)+j|_<dn?(Pn=Ut,Ut=null):Pn=Ut.sibling;var Fn=it(Ge,Ut,et[dn],ft);if(Fn===null){Ut===null&&(Ut=Pn);break}h&&Ut&&Fn.alternate===null&&m(Ge,Ut),Fe=L(Fn,Fe,dn),In===null?Gt=Fn:In.sibling=Fn,In=Fn,Ut=Pn}if(dn===et.length)return _(Ge,Ut),Dn&&Wl(Ge,dn),Gt;if(Ut===null){for(;dndn?(Pn=Ut,Ut=null):Pn=Ut.sibling;var ku=it(Ge,Ut,Fn.value,ft);if(ku===null){Ut===null&&(Ut=Pn);break}h&&Ut&&ku.alternate===null&&m(Ge,Ut),Fe=L(ku,Fe,dn),In===null?Gt=ku:In.sibling=ku,In=ku,Ut=Pn}if(Fn.done)return _(Ge,Ut),Dn&&Wl(Ge,dn),Gt;if(Ut===null){for(;!Fn.done;dn++,Fn=et.next())Fn=pt(Ge,Fn.value,ft),Fn!==null&&(Fe=L(Fn,Fe,dn),In===null?Gt=Fn:In.sibling=Fn,In=Fn);return Dn&&Wl(Ge,dn),Gt}for(Ut=M(Ut);!Fn.done;dn++,Fn=et.next())Fn=ot(Ut,Ge,dn,Fn.value,ft),Fn!==null&&(h&&Fn.alternate!==null&&Ut.delete(Fn.key===null?dn:Fn.key),Fe=L(Fn,Fe,dn),In===null?Gt=Fn:In.sibling=Fn,In=Fn);return h&&Ut.forEach(function(wie){return m(Ge,wie)}),Dn&&Wl(Ge,dn),Gt}function ir(Ge,Fe,et,ft){if(typeof et=="object"&&et!==null&&et.type===v&&et.key===null&&(et=et.props.children),typeof et=="object"&&et!==null){switch(et.$$typeof){case p:e:{for(var Gt=et.key;Fe!==null;){if(Fe.key===Gt){if(Gt=et.type,Gt===v){if(Fe.tag===7){_(Ge,Fe.sibling),ft=j(Fe,et.props.children),ft.return=Ge,Ge=ft;break e}}else if(Fe.elementType===Gt||typeof Gt=="object"&&Gt!==null&&Gt.$$typeof===O&&rh(Gt)===Fe.type){_(Ge,Fe.sibling),ft=j(Fe,et.props),Zg(ft,et),ft.return=Ge,Ge=ft;break e}_(Ge,Fe);break}else m(Ge,Fe);Fe=Fe.sibling}et.type===v?(ft=Qd(et.props.children,Ge.mode,ft,et.key),ft.return=Ge,Ge=ft):(ft=Dy(et.type,et.key,et.props,null,Ge.mode,ft),Zg(ft,et),ft.return=Ge,Ge=ft)}return J(Ge);case b:e:{for(Gt=et.key;Fe!==null;){if(Fe.key===Gt)if(Fe.tag===4&&Fe.stateNode.containerInfo===et.containerInfo&&Fe.stateNode.implementation===et.implementation){_(Ge,Fe.sibling),ft=j(Fe,et.children||[]),ft.return=Ge,Ge=ft;break e}else{_(Ge,Fe);break}else m(Ge,Fe);Fe=Fe.sibling}ft=dE(et,Ge.mode,ft),ft.return=Ge,Ge=ft}return J(Ge);case O:return et=rh(et),ir(Ge,Fe,et,ft)}if(Y(et))return It(Ge,Fe,et,ft);if(I(et)){if(Gt=I(et),typeof Gt!="function")throw Error(r(150));return et=Gt.call(et),Qt(Ge,Fe,et,ft)}if(typeof et.then=="function")return ir(Ge,Fe,By(et),ft);if(et.$$typeof===w)return ir(Ge,Fe,Iy(Ge,et),ft);Hy(Ge,et)}return typeof et=="string"&&et!==""||typeof et=="number"||typeof et=="bigint"?(et=""+et,Fe!==null&&Fe.tag===6?(_(Ge,Fe.sibling),ft=j(Fe,et),ft.return=Ge,Ge=ft):(_(Ge,Fe),ft=uE(et,Ge.mode,ft),ft.return=Ge,Ge=ft),J(Ge)):_(Ge,Fe)}return function(Ge,Fe,et,ft){try{Yg=0;var Gt=ir(Ge,Fe,et,ft);return Zf=null,Gt}catch(Ut){if(Ut===Yf||Ut===Uy)throw Ut;var In=xs(29,Ut,null,Ge.mode);return In.lanes=ft,In.return=Ge,In}finally{}}}var ah=YL(!0),ZL=YL(!1),fu=!1;function SE(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ME(h,m){h=h.updateQueue,m.updateQueue===h&&(m.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function mu(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function pu(h,m,_){var M=h.updateQueue;if(M===null)return null;if(M=M.shared,(Bn&2)!==0){var j=M.pending;return j===null?m.next=m:(m.next=j.next,j.next=m),M.pending=m,m=Ry(h),DL(h,null,_),m}return Py(h,M,m,_),Ry(h)}function Qg(h,m,_){if(m=m.updateQueue,m!==null&&(m=m.shared,(_&4194048)!==0)){var M=m.lanes;M&=h.pendingLanes,_|=M,m.lanes=_,qt(h,_)}}function EE(h,m){var _=h.updateQueue,M=h.alternate;if(M!==null&&(M=M.updateQueue,_===M)){var j=null,L=null;if(_=_.firstBaseUpdate,_!==null){do{var J={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};L===null?j=L=J:L=L.next=J,_=_.next}while(_!==null);L===null?j=L=m:L=L.next=m}else j=L=m;_={baseState:M.baseState,firstBaseUpdate:j,lastBaseUpdate:L,shared:M.shared,callbacks:M.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=m:h.next=m,_.lastBaseUpdate=m}var NE=!1;function Jg(){if(NE){var h=Xf;if(h!==null)throw h}}function ex(h,m,_,M){NE=!1;var j=h.updateQueue;fu=!1;var L=j.firstBaseUpdate,J=j.lastBaseUpdate,de=j.shared.pending;if(de!==null){j.shared.pending=null;var ke=de,tt=ke.next;ke.next=null,J===null?L=tt:J.next=tt,J=ke;var ct=h.alternate;ct!==null&&(ct=ct.updateQueue,de=ct.lastBaseUpdate,de!==J&&(de===null?ct.firstBaseUpdate=tt:de.next=tt,ct.lastBaseUpdate=ke))}if(L!==null){var pt=j.baseState;J=0,ct=tt=ke=null,de=L;do{var it=de.lane&-536870913,ot=it!==de.lane;if(ot?(jn&it)===it:(M&it)===it){it!==0&&it===Kf&&(NE=!0),ct!==null&&(ct=ct.next={lane:0,tag:de.tag,payload:de.payload,callback:null,next:null});e:{var It=h,Qt=de;it=m;var ir=_;switch(Qt.tag){case 1:if(It=Qt.payload,typeof It=="function"){pt=It.call(ir,pt,it);break e}pt=It;break e;case 3:It.flags=It.flags&-65537|128;case 0:if(It=Qt.payload,it=typeof It=="function"?It.call(ir,pt,it):It,it==null)break e;pt=f({},pt,it);break e;case 2:fu=!0}}it=de.callback,it!==null&&(h.flags|=64,ot&&(h.flags|=8192),ot=j.callbacks,ot===null?j.callbacks=[it]:ot.push(it))}else ot={lane:it,tag:de.tag,payload:de.payload,callback:de.callback,next:null},ct===null?(tt=ct=ot,ke=pt):ct=ct.next=ot,J|=it;if(de=de.next,de===null){if(de=j.shared.pending,de===null)break;ot=de,de=ot.next,ot.next=null,j.lastBaseUpdate=ot,j.shared.pending=null}}while(!0);ct===null&&(ke=pt),j.baseState=ke,j.firstBaseUpdate=tt,j.lastBaseUpdate=ct,L===null&&(j.shared.lanes=0),vu|=J,h.lanes=J,h.memoizedState=pt}}function QL(h,m){if(typeof h!="function")throw Error(r(191,h));h.call(m)}function JL(h,m){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)QL(_[h],m)}var Qf=F(null),$y=F(0);function eI(h,m){h=rc,ee($y,h),ee(Qf,m),rc=h|m.baseLanes}function AE(){ee($y,rc),ee(Qf,Qf.current)}function TE(){rc=$y.current,G(Qf),G($y)}var bs=F(null),Ws=null;function gu(h){var m=h.alternate;ee(Zr,Zr.current&1),ee(bs,h),Ws===null&&(m===null||Qf.current!==null||m.memoizedState!==null)&&(Ws=h)}function CE(h){ee(Zr,Zr.current),ee(bs,h),Ws===null&&(Ws=h)}function tI(h){h.tag===22?(ee(Zr,Zr.current),ee(bs,h),Ws===null&&(Ws=h)):xu()}function xu(){ee(Zr,Zr.current),ee(bs,bs.current)}function ys(h){G(bs),Ws===h&&(Ws=null),G(Zr)}var Zr=F(0);function Vy(h){for(var m=h;m!==null;){if(m.tag===13){var _=m.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||ON(_)||LN(_)))return m}else if(m.tag===19&&(m.memoizedProps.revealOrder==="forwards"||m.memoizedProps.revealOrder==="backwards"||m.memoizedProps.revealOrder==="unstable_legacy-backwards"||m.memoizedProps.revealOrder==="together")){if((m.flags&128)!==0)return m}else if(m.child!==null){m.child.return=m,m=m.child;continue}if(m===h)break;for(;m.sibling===null;){if(m.return===null||m.return===h)return null;m=m.return}m.sibling.return=m.return,m=m.sibling}return null}var Xl=0,un=null,nr=null,si=null,Gy=!1,Jf=!1,sh=!1,Wy=0,tx=0,em=null,hre=0;function Br(){throw Error(r(321))}function kE(h,m){if(m===null)return!1;for(var _=0;_L?L:8;var J=Q.T,de={};Q.T=de,GE(h,!1,m,_);try{var ke=j(),tt=Q.S;if(tt!==null&&tt(de,ke),ke!==null&&typeof ke=="object"&&typeof ke.then=="function"){var ct=dre(ke,M);rx(h,m,ct,ws(h))}else rx(h,m,M,ws(h))}catch(pt){rx(h,m,{then:function(){},status:"rejected",reason:pt},ws())}finally{V.p=L,J!==null&&de.types!==null&&(J.types=de.types),Q.T=J}}function xre(){}function $E(h,m,_,M){if(h.tag!==5)throw Error(r(476));var j=jI(h).queue;kI(h,j,m,K,_===null?xre:function(){return PI(h),_(M)})}function jI(h){var m=h.memoizedState;if(m!==null)return m;m={memoizedState:K,baseState:K,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Yl,lastRenderedState:K},next:null};var _={};return m.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Yl,lastRenderedState:_},next:null},h.memoizedState=m,h=h.alternate,h!==null&&(h.memoizedState=m),m}function PI(h){var m=jI(h);m.next===null&&(m=h.alternate.memoizedState),rx(h,m.next.queue,{},ws())}function VE(){return Ji(vx)}function RI(){return Qr().memoizedState}function DI(){return Qr().memoizedState}function bre(h){for(var m=h.return;m!==null;){switch(m.tag){case 24:case 3:var _=ws();h=mu(_);var M=pu(m,h,_);M!==null&&(Qa(M,m,_),Qg(M,m,_)),m={cache:yE()},h.payload=m;return}m=m.return}}function yre(h,m,_){var M=ws();_={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},Qy(h)?LI(m,_):(_=lE(h,m,_,M),_!==null&&(Qa(_,h,M),II(_,m,M)))}function OI(h,m,_){var M=ws();rx(h,m,_,M)}function rx(h,m,_,M){var j={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(Qy(h))LI(m,j);else{var L=h.alternate;if(h.lanes===0&&(L===null||L.lanes===0)&&(L=m.lastRenderedReducer,L!==null))try{var J=m.lastRenderedState,de=L(J,_);if(j.hasEagerState=!0,j.eagerState=de,gs(de,J))return Py(h,m,j,0),fr===null&&jy(),!1}catch{}finally{}if(_=lE(h,m,j,M),_!==null)return Qa(_,h,M),II(_,m,M),!0}return!1}function GE(h,m,_,M){if(M={lane:2,revertLane:SN(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},Qy(h)){if(m)throw Error(r(479))}else m=lE(h,_,M,2),m!==null&&Qa(m,h,2)}function Qy(h){var m=h.alternate;return h===un||m!==null&&m===un}function LI(h,m){Jf=Gy=!0;var _=h.pending;_===null?m.next=m:(m.next=_.next,_.next=m),h.pending=m}function II(h,m,_){if((_&4194048)!==0){var M=m.lanes;M&=h.pendingLanes,_|=M,m.lanes=_,qt(h,_)}}var ix={readContext:Ji,use:Ky,useCallback:Br,useContext:Br,useEffect:Br,useImperativeHandle:Br,useLayoutEffect:Br,useInsertionEffect:Br,useMemo:Br,useReducer:Br,useRef:Br,useState:Br,useDebugValue:Br,useDeferredValue:Br,useTransition:Br,useSyncExternalStore:Br,useId:Br,useHostTransitionStatus:Br,useFormState:Br,useActionState:Br,useOptimistic:Br,useMemoCache:Br,useCacheRefresh:Br};ix.useEffectEvent=Br;var FI={readContext:Ji,use:Ky,useCallback:function(h,m){return ja().memoizedState=[h,m===void 0?null:m],h},useContext:Ji,useEffect:_I,useImperativeHandle:function(h,m,_){_=_!=null?_.concat([h]):null,Yy(4194308,4,EI.bind(null,m,h),_)},useLayoutEffect:function(h,m){return Yy(4194308,4,h,m)},useInsertionEffect:function(h,m){Yy(4,2,h,m)},useMemo:function(h,m){var _=ja();m=m===void 0?null:m;var M=h();if(sh){Ke(!0);try{h()}finally{Ke(!1)}}return _.memoizedState=[M,m],M},useReducer:function(h,m,_){var M=ja();if(_!==void 0){var j=_(m);if(sh){Ke(!0);try{_(m)}finally{Ke(!1)}}}else j=m;return M.memoizedState=M.baseState=j,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:j},M.queue=h,h=h.dispatch=yre.bind(null,un,h),[M.memoizedState,h]},useRef:function(h){var m=ja();return h={current:h},m.memoizedState=h},useState:function(h){h=FE(h);var m=h.queue,_=OI.bind(null,un,m);return m.dispatch=_,[h.memoizedState,_]},useDebugValue:BE,useDeferredValue:function(h,m){var _=ja();return HE(_,h,m)},useTransition:function(){var h=FE(!1);return h=kI.bind(null,un,h.queue,!0,!1),ja().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,m,_){var M=un,j=ja();if(Dn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=m(),fr===null)throw Error(r(349));(jn&127)!==0||aI(M,m,_)}j.memoizedState=_;var L={value:_,getSnapshot:m};return j.queue=L,_I(oI.bind(null,M,L,h),[h]),M.flags|=2048,tm(9,{destroy:void 0},sI.bind(null,M,L,_,m),null),_},useId:function(){var h=ja(),m=fr.identifierPrefix;if(Dn){var _=il,M=rl;_=(M&~(1<<32-Xe(M)-1)).toString(32)+_,m="_"+m+"R_"+_,_=Wy++,0<_&&(m+="H"+_.toString(32)),m+="_"}else _=hre++,m="_"+m+"r_"+_.toString(32)+"_";return h.memoizedState=m},useHostTransitionStatus:VE,useFormState:gI,useActionState:gI,useOptimistic:function(h){var m=ja();m.memoizedState=m.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return m.queue=_,m=GE.bind(null,un,!0,_),_.dispatch=m,[h,m]},useMemoCache:OE,useCacheRefresh:function(){return ja().memoizedState=bre.bind(null,un)},useEffectEvent:function(h){var m=ja(),_={impl:h};return m.memoizedState=_,function(){if((Bn&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},WE={readContext:Ji,use:Ky,useCallback:AI,useContext:Ji,useEffect:zE,useImperativeHandle:NI,useInsertionEffect:SI,useLayoutEffect:MI,useMemo:TI,useReducer:Xy,useRef:vI,useState:function(){return Xy(Yl)},useDebugValue:BE,useDeferredValue:function(h,m){var _=Qr();return CI(_,nr.memoizedState,h,m)},useTransition:function(){var h=Xy(Yl)[0],m=Qr().memoizedState;return[typeof h=="boolean"?h:nx(h),m]},useSyncExternalStore:iI,useId:RI,useHostTransitionStatus:VE,useFormState:xI,useActionState:xI,useOptimistic:function(h,m){var _=Qr();return uI(_,nr,h,m)},useMemoCache:OE,useCacheRefresh:DI};WE.useEffectEvent=wI;var UI={readContext:Ji,use:Ky,useCallback:AI,useContext:Ji,useEffect:zE,useImperativeHandle:NI,useInsertionEffect:SI,useLayoutEffect:MI,useMemo:TI,useReducer:IE,useRef:vI,useState:function(){return IE(Yl)},useDebugValue:BE,useDeferredValue:function(h,m){var _=Qr();return nr===null?HE(_,h,m):CI(_,nr.memoizedState,h,m)},useTransition:function(){var h=IE(Yl)[0],m=Qr().memoizedState;return[typeof h=="boolean"?h:nx(h),m]},useSyncExternalStore:iI,useId:RI,useHostTransitionStatus:VE,useFormState:yI,useActionState:yI,useOptimistic:function(h,m){var _=Qr();return nr!==null?uI(_,nr,h,m):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:OE,useCacheRefresh:DI};UI.useEffectEvent=wI;function qE(h,m,_,M){m=h.memoizedState,_=_(M,m),_=_==null?m:f({},m,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var KE={enqueueSetState:function(h,m,_){h=h._reactInternals;var M=ws(),j=mu(M);j.payload=m,_!=null&&(j.callback=_),m=pu(h,j,M),m!==null&&(Qa(m,h,M),Qg(m,h,M))},enqueueReplaceState:function(h,m,_){h=h._reactInternals;var M=ws(),j=mu(M);j.tag=1,j.payload=m,_!=null&&(j.callback=_),m=pu(h,j,M),m!==null&&(Qa(m,h,M),Qg(m,h,M))},enqueueForceUpdate:function(h,m){h=h._reactInternals;var _=ws(),M=mu(_);M.tag=2,m!=null&&(M.callback=m),m=pu(h,M,_),m!==null&&(Qa(m,h,_),Qg(m,h,_))}};function zI(h,m,_,M,j,L,J){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(M,L,J):m.prototype&&m.prototype.isPureReactComponent?!Vg(_,M)||!Vg(j,L):!0}function BI(h,m,_,M){h=m.state,typeof m.componentWillReceiveProps=="function"&&m.componentWillReceiveProps(_,M),typeof m.UNSAFE_componentWillReceiveProps=="function"&&m.UNSAFE_componentWillReceiveProps(_,M),m.state!==h&&KE.enqueueReplaceState(m,m.state,null)}function oh(h,m){var _=m;if("ref"in m){_={};for(var M in m)M!=="ref"&&(_[M]=m[M])}if(h=h.defaultProps){_===m&&(_=f({},_));for(var j in h)_[j]===void 0&&(_[j]=h[j])}return _}function HI(h){ky(h)}function $I(h){console.error(h)}function VI(h){ky(h)}function Jy(h,m){try{var _=h.onUncaughtError;_(m.value,{componentStack:m.stack})}catch(M){setTimeout(function(){throw M})}}function GI(h,m,_){try{var M=h.onCaughtError;M(_.value,{componentStack:_.stack,errorBoundary:m.tag===1?m.stateNode:null})}catch(j){setTimeout(function(){throw j})}}function XE(h,m,_){return _=mu(_),_.tag=3,_.payload={element:null},_.callback=function(){Jy(h,m)},_}function WI(h){return h=mu(h),h.tag=3,h}function qI(h,m,_,M){var j=_.type.getDerivedStateFromError;if(typeof j=="function"){var L=M.value;h.payload=function(){return j(L)},h.callback=function(){GI(m,_,M)}}var J=_.stateNode;J!==null&&typeof J.componentDidCatch=="function"&&(h.callback=function(){GI(m,_,M),typeof j!="function"&&(_u===null?_u=new Set([this]):_u.add(this));var de=M.stack;this.componentDidCatch(M.value,{componentStack:de!==null?de:""})})}function vre(h,m,_,M,j){if(_.flags|=32768,M!==null&&typeof M=="object"&&typeof M.then=="function"){if(m=_.alternate,m!==null&&qf(m,_,j,!0),_=bs.current,_!==null){switch(_.tag){case 31:case 13:return Ws===null?dv():_.alternate===null&&Hr===0&&(Hr=3),_.flags&=-257,_.flags|=65536,_.lanes=j,M===zy?_.flags|=16384:(m=_.updateQueue,m===null?_.updateQueue=new Set([M]):m.add(M),vN(h,M,j)),!1;case 22:return _.flags|=65536,M===zy?_.flags|=16384:(m=_.updateQueue,m===null?(m={transitions:null,markerInstances:null,retryQueue:new Set([M])},_.updateQueue=m):(_=m.retryQueue,_===null?m.retryQueue=new Set([M]):_.add(M)),vN(h,M,j)),!1}throw Error(r(435,_.tag))}return vN(h,M,j),dv(),!1}if(Dn)return m=bs.current,m!==null?((m.flags&65536)===0&&(m.flags|=256),m.flags|=65536,m.lanes=j,M!==mE&&(h=Error(r(422),{cause:M}),qg(Hs(h,_)))):(M!==mE&&(m=Error(r(423),{cause:M}),qg(Hs(m,_))),h=h.current.alternate,h.flags|=65536,j&=-j,h.lanes|=j,M=Hs(M,_),j=XE(h.stateNode,M,j),EE(h,j),Hr!==4&&(Hr=2)),!1;var L=Error(r(520),{cause:M});if(L=Hs(L,_),hx===null?hx=[L]:hx.push(L),Hr!==4&&(Hr=2),m===null)return!0;M=Hs(M,_),_=m;do{switch(_.tag){case 3:return _.flags|=65536,h=j&-j,_.lanes|=h,h=XE(_.stateNode,M,h),EE(_,h),!1;case 1:if(m=_.type,L=_.stateNode,(_.flags&128)===0&&(typeof m.getDerivedStateFromError=="function"||L!==null&&typeof L.componentDidCatch=="function"&&(_u===null||!_u.has(L))))return _.flags|=65536,j&=-j,_.lanes|=j,j=WI(j),qI(j,h,_,M),EE(_,j),!1}_=_.return}while(_!==null);return!1}var YE=Error(r(461)),oi=!1;function ea(h,m,_,M){m.child=h===null?ZL(m,null,_,M):ah(m,h.child,_,M)}function KI(h,m,_,M,j){_=_.render;var L=m.ref;if("ref"in M){var J={};for(var de in M)de!=="ref"&&(J[de]=M[de])}else J=M;return th(m),M=jE(h,m,_,J,L,j),de=PE(),h!==null&&!oi?(RE(h,m,j),Zl(h,m,j)):(Dn&&de&&hE(m),m.flags|=1,ea(h,m,M,j),m.child)}function XI(h,m,_,M,j){if(h===null){var L=_.type;return typeof L=="function"&&!cE(L)&&L.defaultProps===void 0&&_.compare===null?(m.tag=15,m.type=L,YI(h,m,L,M,j)):(h=Dy(_.type,null,M,m,m.mode,j),h.ref=m.ref,h.return=m,m.child=h)}if(L=h.child,!iN(h,j)){var J=L.memoizedProps;if(_=_.compare,_=_!==null?_:Vg,_(J,M)&&h.ref===m.ref)return Zl(h,m,j)}return m.flags|=1,h=Gl(L,M),h.ref=m.ref,h.return=m,m.child=h}function YI(h,m,_,M,j){if(h!==null){var L=h.memoizedProps;if(Vg(L,M)&&h.ref===m.ref)if(oi=!1,m.pendingProps=M=L,iN(h,j))(h.flags&131072)!==0&&(oi=!0);else return m.lanes=h.lanes,Zl(h,m,j)}return ZE(h,m,_,M,j)}function ZI(h,m,_,M){var j=M.children,L=h!==null?h.memoizedState:null;if(h===null&&m.stateNode===null&&(m.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode==="hidden"){if((m.flags&128)!==0){if(L=L!==null?L.baseLanes|_:_,h!==null){for(M=m.child=h.child,j=0;M!==null;)j=j|M.lanes|M.childLanes,M=M.sibling;M=j&~L}else M=0,m.child=null;return QI(h,m,L,_,M)}if((_&536870912)!==0)m.memoizedState={baseLanes:0,cachePool:null},h!==null&&Fy(m,L!==null?L.cachePool:null),L!==null?eI(m,L):AE(),tI(m);else return M=m.lanes=536870912,QI(h,m,L!==null?L.baseLanes|_:_,_,M)}else L!==null?(Fy(m,L.cachePool),eI(m,L),xu(),m.memoizedState=null):(h!==null&&Fy(m,null),AE(),xu());return ea(h,m,j,_),m.child}function ax(h,m){return h!==null&&h.tag===22||m.stateNode!==null||(m.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),m.sibling}function QI(h,m,_,M,j){var L=_E();return L=L===null?null:{parent:ai._currentValue,pool:L},m.memoizedState={baseLanes:_,cachePool:L},h!==null&&Fy(m,null),AE(),tI(m),h!==null&&qf(h,m,M,!0),m.childLanes=j,null}function ev(h,m){return m=nv({mode:m.mode,children:m.children},h.mode),m.ref=h.ref,h.child=m,m.return=h,m}function JI(h,m,_){return ah(m,h.child,null,_),h=ev(m,m.pendingProps),h.flags|=2,ys(m),m.memoizedState=null,h}function _re(h,m,_){var M=m.pendingProps,j=(m.flags&128)!==0;if(m.flags&=-129,h===null){if(Dn){if(M.mode==="hidden")return h=ev(m,M),m.lanes=536870912,ax(null,h);if(CE(m),(h=_r)?(h=dF(h,Gs),h=h!==null&&h.data==="&"?h:null,h!==null&&(m.memoizedState={dehydrated:h,treeContext:cu!==null?{id:rl,overflow:il}:null,retryLane:536870912,hydrationErrors:null},_=LL(h),_.return=m,m.child=_,Qi=m,_r=null)):h=null,h===null)throw du(m);return m.lanes=536870912,null}return ev(m,M)}var L=h.memoizedState;if(L!==null){var J=L.dehydrated;if(CE(m),j)if(m.flags&256)m.flags&=-257,m=JI(h,m,_);else if(m.memoizedState!==null)m.child=h.child,m.flags|=128,m=null;else throw Error(r(558));else if(oi||qf(h,m,_,!1),j=(_&h.childLanes)!==0,oi||j){if(M=fr,M!==null&&(J=er(M,_),J!==0&&J!==L.retryLane))throw L.retryLane=J,Zd(h,J),Qa(M,h,J),YE;dv(),m=JI(h,m,_)}else h=L.treeContext,_r=qs(J.nextSibling),Qi=m,Dn=!0,uu=null,Gs=!1,h!==null&&UL(m,h),m=ev(m,M),m.flags|=4096;return m}return h=Gl(h.child,{mode:M.mode,children:M.children}),h.ref=m.ref,m.child=h,h.return=m,h}function tv(h,m){var _=m.ref;if(_===null)h!==null&&h.ref!==null&&(m.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(m.flags|=4194816)}}function ZE(h,m,_,M,j){return th(m),_=jE(h,m,_,M,void 0,j),M=PE(),h!==null&&!oi?(RE(h,m,j),Zl(h,m,j)):(Dn&&M&&hE(m),m.flags|=1,ea(h,m,_,j),m.child)}function e5(h,m,_,M,j,L){return th(m),m.updateQueue=null,_=rI(m,M,_,j),nI(h),M=PE(),h!==null&&!oi?(RE(h,m,L),Zl(h,m,L)):(Dn&&M&&hE(m),m.flags|=1,ea(h,m,_,L),m.child)}function t5(h,m,_,M,j){if(th(m),m.stateNode===null){var L=$f,J=_.contextType;typeof J=="object"&&J!==null&&(L=Ji(J)),L=new _(M,L),m.memoizedState=L.state!==null&&L.state!==void 0?L.state:null,L.updater=KE,m.stateNode=L,L._reactInternals=m,L=m.stateNode,L.props=M,L.state=m.memoizedState,L.refs={},SE(m),J=_.contextType,L.context=typeof J=="object"&&J!==null?Ji(J):$f,L.state=m.memoizedState,J=_.getDerivedStateFromProps,typeof J=="function"&&(qE(m,_,J,M),L.state=m.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof L.getSnapshotBeforeUpdate=="function"||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(J=L.state,typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount(),J!==L.state&&KE.enqueueReplaceState(L,L.state,null),ex(m,M,L,j),Jg(),L.state=m.memoizedState),typeof L.componentDidMount=="function"&&(m.flags|=4194308),M=!0}else if(h===null){L=m.stateNode;var de=m.memoizedProps,ke=oh(_,de);L.props=ke;var tt=L.context,ct=_.contextType;J=$f,typeof ct=="object"&&ct!==null&&(J=Ji(ct));var pt=_.getDerivedStateFromProps;ct=typeof pt=="function"||typeof L.getSnapshotBeforeUpdate=="function",de=m.pendingProps!==de,ct||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(de||tt!==J)&&BI(m,L,M,J),fu=!1;var it=m.memoizedState;L.state=it,ex(m,M,L,j),Jg(),tt=m.memoizedState,de||it!==tt||fu?(typeof pt=="function"&&(qE(m,_,pt,M),tt=m.memoizedState),(ke=fu||zI(m,_,ke,M,it,tt,J))?(ct||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount()),typeof L.componentDidMount=="function"&&(m.flags|=4194308)):(typeof L.componentDidMount=="function"&&(m.flags|=4194308),m.memoizedProps=M,m.memoizedState=tt),L.props=M,L.state=tt,L.context=J,M=ke):(typeof L.componentDidMount=="function"&&(m.flags|=4194308),M=!1)}else{L=m.stateNode,ME(h,m),J=m.memoizedProps,ct=oh(_,J),L.props=ct,pt=m.pendingProps,it=L.context,tt=_.contextType,ke=$f,typeof tt=="object"&&tt!==null&&(ke=Ji(tt)),de=_.getDerivedStateFromProps,(tt=typeof de=="function"||typeof L.getSnapshotBeforeUpdate=="function")||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(J!==pt||it!==ke)&&BI(m,L,M,ke),fu=!1,it=m.memoizedState,L.state=it,ex(m,M,L,j),Jg();var ot=m.memoizedState;J!==pt||it!==ot||fu||h!==null&&h.dependencies!==null&&Ly(h.dependencies)?(typeof de=="function"&&(qE(m,_,de,M),ot=m.memoizedState),(ct=fu||zI(m,_,ct,M,it,ot,ke)||h!==null&&h.dependencies!==null&&Ly(h.dependencies))?(tt||typeof L.UNSAFE_componentWillUpdate!="function"&&typeof L.componentWillUpdate!="function"||(typeof L.componentWillUpdate=="function"&&L.componentWillUpdate(M,ot,ke),typeof L.UNSAFE_componentWillUpdate=="function"&&L.UNSAFE_componentWillUpdate(M,ot,ke)),typeof L.componentDidUpdate=="function"&&(m.flags|=4),typeof L.getSnapshotBeforeUpdate=="function"&&(m.flags|=1024)):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(m.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(m.flags|=1024),m.memoizedProps=M,m.memoizedState=ot),L.props=M,L.state=ot,L.context=ke,M=ct):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(m.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(m.flags|=1024),M=!1)}return L=M,tv(h,m),M=(m.flags&128)!==0,L||M?(L=m.stateNode,_=M&&typeof _.getDerivedStateFromError!="function"?null:L.render(),m.flags|=1,h!==null&&M?(m.child=ah(m,h.child,null,j),m.child=ah(m,null,_,j)):ea(h,m,_,j),m.memoizedState=L.state,h=m.child):h=Zl(h,m,j),h}function n5(h,m,_,M){return Jd(),m.flags|=256,ea(h,m,_,M),m.child}var QE={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function JE(h){return{baseLanes:h,cachePool:GL()}}function eN(h,m,_){return h=h!==null?h.childLanes&~_:0,m&&(h|=_s),h}function r5(h,m,_){var M=m.pendingProps,j=!1,L=(m.flags&128)!==0,J;if((J=L)||(J=h!==null&&h.memoizedState===null?!1:(Zr.current&2)!==0),J&&(j=!0,m.flags&=-129),J=(m.flags&32)!==0,m.flags&=-33,h===null){if(Dn){if(j?gu(m):xu(),(h=_r)?(h=dF(h,Gs),h=h!==null&&h.data!=="&"?h:null,h!==null&&(m.memoizedState={dehydrated:h,treeContext:cu!==null?{id:rl,overflow:il}:null,retryLane:536870912,hydrationErrors:null},_=LL(h),_.return=m,m.child=_,Qi=m,_r=null)):h=null,h===null)throw du(m);return LN(h)?m.lanes=32:m.lanes=536870912,null}var de=M.children;return M=M.fallback,j?(xu(),j=m.mode,de=nv({mode:"hidden",children:de},j),M=Qd(M,j,_,null),de.return=m,M.return=m,de.sibling=M,m.child=de,M=m.child,M.memoizedState=JE(_),M.childLanes=eN(h,J,_),m.memoizedState=QE,ax(null,M)):(gu(m),tN(m,de))}var ke=h.memoizedState;if(ke!==null&&(de=ke.dehydrated,de!==null)){if(L)m.flags&256?(gu(m),m.flags&=-257,m=nN(h,m,_)):m.memoizedState!==null?(xu(),m.child=h.child,m.flags|=128,m=null):(xu(),de=M.fallback,j=m.mode,M=nv({mode:"visible",children:M.children},j),de=Qd(de,j,_,null),de.flags|=2,M.return=m,de.return=m,M.sibling=de,m.child=M,ah(m,h.child,null,_),M=m.child,M.memoizedState=JE(_),M.childLanes=eN(h,J,_),m.memoizedState=QE,m=ax(null,M));else if(gu(m),LN(de)){if(J=de.nextSibling&&de.nextSibling.dataset,J)var tt=J.dgst;J=tt,M=Error(r(419)),M.stack="",M.digest=J,qg({value:M,source:null,stack:null}),m=nN(h,m,_)}else if(oi||qf(h,m,_,!1),J=(_&h.childLanes)!==0,oi||J){if(J=fr,J!==null&&(M=er(J,_),M!==0&&M!==ke.retryLane))throw ke.retryLane=M,Zd(h,M),Qa(J,h,M),YE;ON(de)||dv(),m=nN(h,m,_)}else ON(de)?(m.flags|=192,m.child=h.child,m=null):(h=ke.treeContext,_r=qs(de.nextSibling),Qi=m,Dn=!0,uu=null,Gs=!1,h!==null&&UL(m,h),m=tN(m,M.children),m.flags|=4096);return m}return j?(xu(),de=M.fallback,j=m.mode,ke=h.child,tt=ke.sibling,M=Gl(ke,{mode:"hidden",children:M.children}),M.subtreeFlags=ke.subtreeFlags&65011712,tt!==null?de=Gl(tt,de):(de=Qd(de,j,_,null),de.flags|=2),de.return=m,M.return=m,M.sibling=de,m.child=M,ax(null,M),M=m.child,de=h.child.memoizedState,de===null?de=JE(_):(j=de.cachePool,j!==null?(ke=ai._currentValue,j=j.parent!==ke?{parent:ke,pool:ke}:j):j=GL(),de={baseLanes:de.baseLanes|_,cachePool:j}),M.memoizedState=de,M.childLanes=eN(h,J,_),m.memoizedState=QE,ax(h.child,M)):(gu(m),_=h.child,h=_.sibling,_=Gl(_,{mode:"visible",children:M.children}),_.return=m,_.sibling=null,h!==null&&(J=m.deletions,J===null?(m.deletions=[h],m.flags|=16):J.push(h)),m.child=_,m.memoizedState=null,_)}function tN(h,m){return m=nv({mode:"visible",children:m},h.mode),m.return=h,h.child=m}function nv(h,m){return h=xs(22,h,null,m),h.lanes=0,h}function nN(h,m,_){return ah(m,h.child,null,_),h=tN(m,m.pendingProps.children),h.flags|=2,m.memoizedState=null,h}function i5(h,m,_){h.lanes|=m;var M=h.alternate;M!==null&&(M.lanes|=m),xE(h.return,m,_)}function rN(h,m,_,M,j,L){var J=h.memoizedState;J===null?h.memoizedState={isBackwards:m,rendering:null,renderingStartTime:0,last:M,tail:_,tailMode:j,treeForkCount:L}:(J.isBackwards=m,J.rendering=null,J.renderingStartTime=0,J.last=M,J.tail=_,J.tailMode=j,J.treeForkCount=L)}function a5(h,m,_){var M=m.pendingProps,j=M.revealOrder,L=M.tail;M=M.children;var J=Zr.current,de=(J&2)!==0;if(de?(J=J&1|2,m.flags|=128):J&=1,ee(Zr,J),ea(h,m,M,_),M=Dn?Wg:0,!de&&h!==null&&(h.flags&128)!==0)e:for(h=m.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&i5(h,_,m);else if(h.tag===19)i5(h,_,m);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===m)break e;for(;h.sibling===null;){if(h.return===null||h.return===m)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(j){case"forwards":for(_=m.child,j=null;_!==null;)h=_.alternate,h!==null&&Vy(h)===null&&(j=_),_=_.sibling;_=j,_===null?(j=m.child,m.child=null):(j=_.sibling,_.sibling=null),rN(m,!1,j,_,L,M);break;case"backwards":case"unstable_legacy-backwards":for(_=null,j=m.child,m.child=null;j!==null;){if(h=j.alternate,h!==null&&Vy(h)===null){m.child=j;break}h=j.sibling,j.sibling=_,_=j,j=h}rN(m,!0,_,null,L,M);break;case"together":rN(m,!1,null,null,void 0,M);break;default:m.memoizedState=null}return m.child}function Zl(h,m,_){if(h!==null&&(m.dependencies=h.dependencies),vu|=m.lanes,(_&m.childLanes)===0)if(h!==null){if(qf(h,m,_,!1),(_&m.childLanes)===0)return null}else return null;if(h!==null&&m.child!==h.child)throw Error(r(153));if(m.child!==null){for(h=m.child,_=Gl(h,h.pendingProps),m.child=_,_.return=m;h.sibling!==null;)h=h.sibling,_=_.sibling=Gl(h,h.pendingProps),_.return=m;_.sibling=null}return m.child}function iN(h,m){return(h.lanes&m)!==0?!0:(h=h.dependencies,!!(h!==null&&Ly(h)))}function wre(h,m,_){switch(m.tag){case 3:ue(m,m.stateNode.containerInfo),hu(m,ai,h.memoizedState.cache),Jd();break;case 27:case 5:Se(m);break;case 4:ue(m,m.stateNode.containerInfo);break;case 10:hu(m,m.type,m.memoizedProps.value);break;case 31:if(m.memoizedState!==null)return m.flags|=128,CE(m),null;break;case 13:var M=m.memoizedState;if(M!==null)return M.dehydrated!==null?(gu(m),m.flags|=128,null):(_&m.child.childLanes)!==0?r5(h,m,_):(gu(m),h=Zl(h,m,_),h!==null?h.sibling:null);gu(m);break;case 19:var j=(h.flags&128)!==0;if(M=(_&m.childLanes)!==0,M||(qf(h,m,_,!1),M=(_&m.childLanes)!==0),j){if(M)return a5(h,m,_);m.flags|=128}if(j=m.memoizedState,j!==null&&(j.rendering=null,j.tail=null,j.lastEffect=null),ee(Zr,Zr.current),M)break;return null;case 22:return m.lanes=0,ZI(h,m,_,m.pendingProps);case 24:hu(m,ai,h.memoizedState.cache)}return Zl(h,m,_)}function s5(h,m,_){if(h!==null)if(h.memoizedProps!==m.pendingProps)oi=!0;else{if(!iN(h,_)&&(m.flags&128)===0)return oi=!1,wre(h,m,_);oi=(h.flags&131072)!==0}else oi=!1,Dn&&(m.flags&1048576)!==0&&FL(m,Wg,m.index);switch(m.lanes=0,m.tag){case 16:e:{var M=m.pendingProps;if(h=rh(m.elementType),m.type=h,typeof h=="function")cE(h)?(M=oh(h,M),m.tag=1,m=t5(null,m,h,M,_)):(m.tag=0,m=ZE(null,m,h,M,_));else{if(h!=null){var j=h.$$typeof;if(j===A){m.tag=11,m=KI(null,m,h,M,_);break e}else if(j===T){m.tag=14,m=XI(null,m,h,M,_);break e}}throw m=D(h)||h,Error(r(306,m,""))}}return m;case 0:return ZE(h,m,m.type,m.pendingProps,_);case 1:return M=m.type,j=oh(M,m.pendingProps),t5(h,m,M,j,_);case 3:e:{if(ue(m,m.stateNode.containerInfo),h===null)throw Error(r(387));M=m.pendingProps;var L=m.memoizedState;j=L.element,ME(h,m),ex(m,M,null,_);var J=m.memoizedState;if(M=J.cache,hu(m,ai,M),M!==L.cache&&bE(m,[ai],_,!0),Jg(),M=J.element,L.isDehydrated)if(L={element:M,isDehydrated:!1,cache:J.cache},m.updateQueue.baseState=L,m.memoizedState=L,m.flags&256){m=n5(h,m,M,_);break e}else if(M!==j){j=Hs(Error(r(424)),m),qg(j),m=n5(h,m,M,_);break e}else{switch(h=m.stateNode.containerInfo,h.nodeType){case 9:h=h.body;break;default:h=h.nodeName==="HTML"?h.ownerDocument.body:h}for(_r=qs(h.firstChild),Qi=m,Dn=!0,uu=null,Gs=!0,_=ZL(m,null,M,_),m.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(Jd(),M===j){m=Zl(h,m,_);break e}ea(h,m,M,_)}m=m.child}return m;case 26:return tv(h,m),h===null?(_=xF(m.type,null,m.pendingProps,null))?m.memoizedState=_:Dn||(_=m.type,h=m.pendingProps,M=bv(ce.current).createElement(_),M[Bt]=m,M[ln]=h,ta(M,_,h),De(M),m.stateNode=M):m.memoizedState=xF(m.type,h.memoizedProps,m.pendingProps,h.memoizedState),null;case 27:return Se(m),h===null&&Dn&&(M=m.stateNode=mF(m.type,m.pendingProps,ce.current),Qi=m,Gs=!0,j=_r,Eu(m.type)?(IN=j,_r=qs(M.firstChild)):_r=j),ea(h,m,m.pendingProps.children,_),tv(h,m),h===null&&(m.flags|=4194304),m.child;case 5:return h===null&&Dn&&((j=M=_r)&&(M=Qre(M,m.type,m.pendingProps,Gs),M!==null?(m.stateNode=M,Qi=m,_r=qs(M.firstChild),Gs=!1,j=!0):j=!1),j||du(m)),Se(m),j=m.type,L=m.pendingProps,J=h!==null?h.memoizedProps:null,M=L.children,PN(j,L)?M=null:J!==null&&PN(j,J)&&(m.flags|=32),m.memoizedState!==null&&(j=jE(h,m,fre,null,null,_),vx._currentValue=j),tv(h,m),ea(h,m,M,_),m.child;case 6:return h===null&&Dn&&((h=_=_r)&&(_=Jre(_,m.pendingProps,Gs),_!==null?(m.stateNode=_,Qi=m,_r=null,h=!0):h=!1),h||du(m)),null;case 13:return r5(h,m,_);case 4:return ue(m,m.stateNode.containerInfo),M=m.pendingProps,h===null?m.child=ah(m,null,M,_):ea(h,m,M,_),m.child;case 11:return KI(h,m,m.type,m.pendingProps,_);case 7:return ea(h,m,m.pendingProps,_),m.child;case 8:return ea(h,m,m.pendingProps.children,_),m.child;case 12:return ea(h,m,m.pendingProps.children,_),m.child;case 10:return M=m.pendingProps,hu(m,m.type,M.value),ea(h,m,M.children,_),m.child;case 9:return j=m.type._context,M=m.pendingProps.children,th(m),j=Ji(j),M=M(j),m.flags|=1,ea(h,m,M,_),m.child;case 14:return XI(h,m,m.type,m.pendingProps,_);case 15:return YI(h,m,m.type,m.pendingProps,_);case 19:return a5(h,m,_);case 31:return _re(h,m,_);case 22:return ZI(h,m,_,m.pendingProps);case 24:return th(m),M=Ji(ai),h===null?(j=_E(),j===null&&(j=fr,L=yE(),j.pooledCache=L,L.refCount++,L!==null&&(j.pooledCacheLanes|=_),j=L),m.memoizedState={parent:M,cache:j},SE(m),hu(m,ai,j)):((h.lanes&_)!==0&&(ME(h,m),ex(m,null,null,_),Jg()),j=h.memoizedState,L=m.memoizedState,j.parent!==M?(j={parent:M,cache:M},m.memoizedState=j,m.lanes===0&&(m.memoizedState=m.updateQueue.baseState=j),hu(m,ai,M)):(M=L.cache,hu(m,ai,M),M!==j.cache&&bE(m,[ai],_,!0))),ea(h,m,m.pendingProps.children,_),m.child;case 29:throw m.pendingProps}throw Error(r(156,m.tag))}function Ql(h){h.flags|=4}function aN(h,m,_,M,j){if((m=(h.mode&32)!==0)&&(m=!1),m){if(h.flags|=16777216,(j&335544128)===j)if(h.stateNode.complete)h.flags|=8192;else if(R5())h.flags|=8192;else throw ih=zy,wE}else h.flags&=-16777217}function o5(h,m){if(m.type!=="stylesheet"||(m.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!wF(m))if(R5())h.flags|=8192;else throw ih=zy,wE}function rv(h,m){m!==null&&(h.flags|=4),h.flags&16384&&(m=h.tag!==22?Nt():536870912,h.lanes|=m,am|=m)}function sx(h,m){if(!Dn)switch(h.tailMode){case"hidden":m=h.tail;for(var _=null;m!==null;)m.alternate!==null&&(_=m),m=m.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var M=null;_!==null;)_.alternate!==null&&(M=_),_=_.sibling;M===null?m||h.tail===null?h.tail=null:h.tail.sibling=null:M.sibling=null}}function wr(h){var m=h.alternate!==null&&h.alternate.child===h.child,_=0,M=0;if(m)for(var j=h.child;j!==null;)_|=j.lanes|j.childLanes,M|=j.subtreeFlags&65011712,M|=j.flags&65011712,j.return=h,j=j.sibling;else for(j=h.child;j!==null;)_|=j.lanes|j.childLanes,M|=j.subtreeFlags,M|=j.flags,j.return=h,j=j.sibling;return h.subtreeFlags|=M,h.childLanes=_,m}function Sre(h,m,_){var M=m.pendingProps;switch(fE(m),m.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return wr(m),null;case 1:return wr(m),null;case 3:return _=m.stateNode,M=null,h!==null&&(M=h.memoizedState.cache),m.memoizedState.cache!==M&&(m.flags|=2048),Kl(ai),me(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(Wf(m)?Ql(m):h===null||h.memoizedState.isDehydrated&&(m.flags&256)===0||(m.flags|=1024,pE())),wr(m),null;case 26:var j=m.type,L=m.memoizedState;return h===null?(Ql(m),L!==null?(wr(m),o5(m,L)):(wr(m),aN(m,j,null,M,_))):L?L!==h.memoizedState?(Ql(m),wr(m),o5(m,L)):(wr(m),m.flags&=-16777217):(h=h.memoizedProps,h!==M&&Ql(m),wr(m),aN(m,j,h,M,_)),null;case 27:if(be(m),_=ce.current,j=m.type,h!==null&&m.stateNode!=null)h.memoizedProps!==M&&Ql(m);else{if(!M){if(m.stateNode===null)throw Error(r(166));return wr(m),null}h=te.current,Wf(m)?zL(m):(h=mF(j,M,_),m.stateNode=h,Ql(m))}return wr(m),null;case 5:if(be(m),j=m.type,h!==null&&m.stateNode!=null)h.memoizedProps!==M&&Ql(m);else{if(!M){if(m.stateNode===null)throw Error(r(166));return wr(m),null}if(L=te.current,Wf(m))zL(m);else{var J=bv(ce.current);switch(L){case 1:L=J.createElementNS("http://www.w3.org/2000/svg",j);break;case 2:L=J.createElementNS("http://www.w3.org/1998/Math/MathML",j);break;default:switch(j){case"svg":L=J.createElementNS("http://www.w3.org/2000/svg",j);break;case"math":L=J.createElementNS("http://www.w3.org/1998/Math/MathML",j);break;case"script":L=J.createElement("div"),L.innerHTML=" - + +