Browse Source

Add File Manager and enhanced Queue features

File Manager:
- Add full File Manager page for browsing local library files
- Implement folder hierarchy with create/rename/delete operations
- Add file uploads with drag-and-drop support
- Enable adding sliced files directly to print queue
- Add linked folders with external path mounting

Queue Enhancements:
- Add plate selection for multi-plate 3MF files
- Add print options (bed levelling, flow calibration, vibration
  calibration, layer inspection, timelapse, use AMS)
- Fix print_count increment to only occur on actual prints

Testing:
- Add backend integration tests for library API (15 tests)
- Add backend integration tests for queue print options (8 tests)
- Add frontend tests for EditQueueItemModal (10 tests)
- Suppress console noise in frontend test suite

Closes #94
maziggy 4 months ago
parent
commit
d48abe0f6a
36 changed files with 4447 additions and 129 deletions
  1. 10 0
      CHANGELOG.md
  2. 1168 0
      backend/app/api/routes/library.py
  3. 14 0
      backend/app/api/routes/print_queue.py
  4. 21 1
      backend/app/api/routes/settings.py
  5. 6 2
      backend/app/api/routes/webhook.py
  6. 51 0
      backend/app/core/database.py
  7. 2 0
      backend/app/main.py
  8. 3 0
      backend/app/models/__init__.py
  9. 86 0
      backend/app/models/library.py
  10. 11 0
      backend/app/models/print_queue.py
  11. 236 0
      backend/app/schemas/library.py
  12. 25 0
      backend/app/schemas/print_queue.py
  13. 12 0
      backend/app/schemas/settings.py
  14. 8 1
      backend/app/services/print_scheduler.py
  15. 313 0
      backend/tests/integration/test_library_api.py
  16. 75 0
      backend/tests/integration/test_print_queue_api.py
  17. 2 0
      frontend/src/App.tsx
  18. 257 0
      frontend/src/__tests__/components/EditQueueItemModal.test.tsx
  19. 24 0
      frontend/src/__tests__/mocks/handlers.ts
  20. 7 4
      frontend/src/__tests__/setup.ts
  21. 227 0
      frontend/src/api/client.ts
  22. 135 8
      frontend/src/components/EditQueueItemModal.tsx
  23. 2 1
      frontend/src/components/Layout.tsx
  24. 3 3
      frontend/src/hooks/useWebSocket.ts
  25. 1 0
      frontend/src/i18n/locales/de.ts
  26. 1 0
      frontend/src/i18n/locales/en.ts
  27. 35 0
      frontend/src/pages/ArchivesPage.tsx
  28. 1600 0
      frontend/src/pages/FileManagerPage.tsx
  29. 27 102
      frontend/src/pages/ProjectDetailPage.tsx
  30. 61 4
      frontend/src/pages/SettingsPage.tsx
  31. 22 1
      frontend/src/pages/SystemInfoPage.tsx
  32. 0 0
      static/assets/index-BNfnoADT.js
  33. 0 0
      static/assets/index-CBKbW_8F.js
  34. 0 0
      static/assets/index-DMQ1f41h.css
  35. 0 0
      static/assets/index-Dzh7xD3q.css
  36. 2 2
      static/index.html

+ 10 - 0
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

+ 1168 - 0
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,
+    }

+ 14 - 0
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",
     )

+ 21 - 1
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"),
             )

+ 6 - 2
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))

+ 51 - 0
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."""

+ 2 - 0
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)

+ 3 - 0
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",
 ]

+ 86 - 0
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

+ 11 - 0
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")
 

+ 236 - 0
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]

+ 25 - 0
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

+ 12 - 0
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

+ 8 - 1
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:

+ 313 - 0
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()

+ 75 - 0
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):

+ 2 - 0
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() {
                 <Route path="maintenance" element={<MaintenancePage />} />
                 <Route path="projects" element={<ProjectsPage />} />
                 <Route path="projects/:id" element={<ProjectDetailPage />} />
+                <Route path="files" element={<FileManagerPage />} />
                 <Route path="settings" element={<SettingsPage />} />
                 <Route path="system" element={<SystemInfoPage />} />
                 <Route path="external/:id" element={<ExternalLinkPage />} />

+ 257 - 0
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> = {}): 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> = {}): 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(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
+    });
+
+    it('shows printer selector label', () => {
+      const item = createMockQueueItem();
+      const printers = [createMockPrinter({ name: 'My Printer' })];
+
+      render(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      // The printer label should be present
+      expect(screen.getByText('Printer')).toBeInTheDocument();
+    });
+
+    it('shows print options toggle', () => {
+      const item = createMockQueueItem();
+      const printers = [createMockPrinter()];
+
+      render(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      expect(screen.getByText('Print Options')).toBeInTheDocument();
+    });
+  });
+
+  describe('print options', () => {
+    it('has print options toggle button', () => {
+      const item = createMockQueueItem();
+      const printers = [createMockPrinter()];
+
+      render(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      // 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(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      // 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(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      const saveButton = screen.getByRole('button', { name: /save/i });
+      expect(saveButton).toBeInTheDocument();
+    });
+
+    it('has cancel button', () => {
+      const item = createMockQueueItem();
+      const printers = [createMockPrinter()];
+
+      render(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      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(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      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(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      expect(screen.getByText('Queue Only')).toBeInTheDocument();
+    });
+
+    it('shows power off option', () => {
+      const item = createMockQueueItem();
+      const printers = [createMockPrinter()];
+
+      render(
+        <EditQueueItemModal
+          item={item}
+          printers={printers}
+          onClose={mockOnClose}
+          onSave={mockOnSave}
+        />
+      );
+
+      expect(screen.getByText(/power off/i)).toBeInTheDocument();
+    });
+  });
+});

+ 24 - 0
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,
+    });
+  }),
 ];

+ 7 - 4
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(() => {});

+ 227 - 0
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<AppSettings>;
@@ -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<SystemInfo>('/system/info'),
+
+  // Library (File Manager)
+  getLibraryFolders: () => request<LibraryFolderTree[]>('/library/folders'),
+  createLibraryFolder: (data: LibraryFolderCreate) =>
+    request<LibraryFolder>('/library/folders', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateLibraryFolder: (id: number, data: LibraryFolderUpdate) =>
+    request<LibraryFolder>(`/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<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),
+  getLibraryFoldersByArchive: (archiveId: number) =>
+    request<LibraryFolder[]>(`/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<LibraryFileListItem[]>(`/library/files?${params}`);
+  },
+  getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
+  uploadLibraryFile: async (file: File, folderId?: number | null): Promise<LibraryFileUploadResponse> => {
+    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<LibraryFile>(`/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<LibraryStats>('/library/stats'),
+  addLibraryFilesToQueue: (fileIds: number[]) =>
+    request<AddToQueueResponse>('/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<string, unknown> | 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<string, unknown> | null;
+}
+
+export interface LibraryStats {
+  total_files: number;
+  total_folders: number;
+  total_size_bytes: number;
+  files_by_type: Record<string, number>;
+  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;

+ 135 - 8
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<number | null>(item.printer_id);
+  const [selectedPlate, setSelectedPlate] = useState<number | null>(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<Record<number, number>>(() => {
@@ -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) {
               )}
             </div>
 
+            {/* Plate selection - show when multi-plate file detected */}
+            {isMultiPlate && plates.length > 1 && (
+              <div>
+                <div className="flex items-center gap-2 mb-2">
+                  <Layers className="w-4 h-4 text-bambu-gray" />
+                  <label className="text-sm text-bambu-gray">Select Plate to Print</label>
+                  {!selectedPlate && (
+                    <span className="text-xs text-orange-400 flex items-center gap-1">
+                      <AlertTriangle className="w-3 h-3" />
+                      Selection required
+                    </span>
+                  )}
+                </div>
+                <div className="grid grid-cols-2 gap-2">
+                  {plates.map((plate) => (
+                    <button
+                      key={plate.index}
+                      type="button"
+                      onClick={() => setSelectedPlate(plate.index)}
+                      className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+                        selectedPlate === plate.index
+                          ? 'border-bambu-green bg-bambu-green/10'
+                          : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
+                      }`}
+                    >
+                      {plate.has_thumbnail && plate.thumbnail_url ? (
+                        <img
+                          src={plate.thumbnail_url}
+                          alt={`Plate ${plate.index}`}
+                          className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
+                        />
+                      ) : (
+                        <div className="w-10 h-10 rounded bg-bambu-dark-tertiary flex items-center justify-center">
+                          <Layers className="w-5 h-5 text-bambu-gray" />
+                        </div>
+                      )}
+                      <div className="min-w-0 flex-1">
+                        <p className="text-sm text-white font-medium truncate">
+                          Plate {plate.index}
+                        </p>
+                        <p className="text-xs text-bambu-gray truncate">
+                          {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                        </p>
+                      </div>
+                      {selectedPlate === plate.index && (
+                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </button>
+                  ))}
+                </div>
+              </div>
+            )}
+
             {/* Filament Mapping Section */}
-            {printerId !== null && hasFilamentReqs && (
+            {printerId !== null && (isMultiPlate ? selectedPlate !== null : true) && hasFilamentReqs && (
               <div>
                 <button
                   type="button"
@@ -510,6 +594,49 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
               </div>
             )}
 
+            {/* Print Options */}
+            <div>
+              <button
+                type="button"
+                onClick={() => setShowPrintOptions(!showPrintOptions)}
+                className="flex items-center gap-2 text-sm text-bambu-gray hover:text-white transition-colors w-full"
+              >
+                <Settings className="w-4 h-4" />
+                <span>Print Options</span>
+                {showPrintOptions ? <ChevronUp className="w-4 h-4 ml-auto" /> : <ChevronDown className="w-4 h-4 ml-auto" />}
+              </button>
+              {showPrintOptions && (
+                <div className="mt-2 bg-bambu-dark rounded-lg p-3 space-y-2">
+                  {[
+                    { 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 }) => (
+                    <label key={key} className="flex items-center justify-between cursor-pointer group">
+                      <div>
+                        <span className="text-sm text-white">{label}</span>
+                        <p className="text-xs text-bambu-gray">{desc}</p>
+                      </div>
+                      <div
+                        className={`relative w-10 h-5 rounded-full transition-colors ${
+                          printOptions[key as keyof typeof printOptions] ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                        }`}
+                        onClick={() => setPrintOptions((prev) => ({ ...prev, [key]: !prev[key as keyof typeof printOptions] }))}
+                      >
+                        <div
+                          className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
+                            printOptions[key as keyof typeof printOptions] ? 'translate-x-5' : 'translate-x-0.5'
+                          }`}
+                        />
+                      </div>
+                    </label>
+                  ))}
+                </div>
+              )}
+            </div>
+
             {/* Schedule type */}
             <div>
               <label className="block text-sm text-bambu-gray mb-2">When to print</label>

+ 2 - 1
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' },
 ];
 

+ 3 - 3
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();
     };
 

+ 1 - 0
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',

+ 1 - 0
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',

+ 35 - 0
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({
             )}
           </button>
         )}
+        {/* Linked folder badge */}
+        {linkedFolders && linkedFolders.length > 0 && (
+          <Link
+            to={`/files?folder=${linkedFolders[0].id}`}
+            className="absolute bottom-2 p-1.5 rounded bg-black/60 hover:bg-black/80 transition-colors"
+            onClick={(e) => 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') }}
+          >
+            <FolderOpen className="w-4 h-4 text-yellow-400" />
+          </Link>
+        )}
       </div>
 
       <CardContent className="p-4 flex-1 flex flex-col">
@@ -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({
                 <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />
               </span>
             )}
+            {linkedFolders && linkedFolders.length > 0 && (
+              <Link
+                to={`/files?folder=${linkedFolders[0].id}`}
+                className="flex-shrink-0"
+                title={`Open folder: ${linkedFolders[0].name}`}
+                onClick={(e) => e.stopPropagation()}
+              >
+                <FolderOpen className="w-3.5 h-3.5 text-yellow-400" />
+              </Link>
+            )}
           </div>
           {archive.filament_type && (
             <div className="flex items-center gap-1.5 mt-0.5">

+ 1600 - 0
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 (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">New Folder</h2>
+        </div>
+        <form onSubmit={handleSubmit} className="p-4 space-y-4">
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              Folder Name
+            </label>
+            <input
+              type="text"
+              value={name}
+              onChange={(e) => 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
+            />
+          </div>
+          <div className="flex justify-end gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={!name.trim() || isLoading}>
+              {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
+            </Button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}
+
+// 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<number | null>(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 (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-sm border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">Move {selectedFiles.length} File(s)</h2>
+        </div>
+        <div className="p-4 space-y-4">
+          <div className="max-h-64 overflow-y-auto space-y-1">
+            {flatFolders.map((folder) => (
+              <button
+                key={folder.id ?? 'root'}
+                onClick={() => setTargetFolder(folder.id)}
+                disabled={folder.id === currentFolderId}
+                className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
+                  targetFolder === folder.id
+                    ? 'bg-bambu-green/20 text-bambu-green'
+                    : folder.id === currentFolderId
+                    ? 'opacity-50 cursor-not-allowed text-bambu-gray'
+                    : 'hover:bg-bambu-dark text-white'
+                }`}
+                style={{ paddingLeft: `${12 + folder.depth * 16}px` }}
+              >
+                <FolderOpen className="w-4 h-4" />
+                {folder.name}
+                {folder.id === currentFolderId && <span className="text-xs text-bambu-gray ml-auto">(current)</span>}
+              </button>
+            ))}
+          </div>
+          <div className="flex justify-end gap-2 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button onClick={() => onMove(targetFolder)} disabled={isLoading}>
+              {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Move'}
+            </Button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// 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<number | null>(
+    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 (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
+          <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+            <Link2 className="w-5 h-5 text-bambu-green" />
+            Link Folder
+          </h2>
+          <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        <div className="p-4 space-y-4">
+          <p className="text-sm text-bambu-gray">
+            Link "<span className="text-white">{folder.name}</span>" to a project or archive for quick access.
+          </p>
+
+          {/* Link type selector */}
+          <div className="flex gap-2">
+            <button
+              onClick={() => { setLinkType('project'); setSelectedId(null); }}
+              className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
+                linkType === 'project'
+                  ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+              }`}
+            >
+              <Briefcase className="w-4 h-4" />
+              Project
+            </button>
+            <button
+              onClick={() => { setLinkType('archive'); setSelectedId(null); }}
+              className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
+                linkType === 'archive'
+                  ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                  : 'border-bambu-dark-tertiary text-bambu-gray hover:text-white'
+              }`}
+            >
+              <ArchiveIcon className="w-4 h-4" />
+              Archive
+            </button>
+          </div>
+
+          {/* Selection list */}
+          <div className="max-h-64 overflow-y-auto space-y-1 bg-bambu-dark rounded-lg p-2">
+            {linkType === 'project' ? (
+              projects && projects.length > 0 ? (
+                projects.map((project) => (
+                  <button
+                    key={project.id}
+                    onClick={() => setSelectedId(project.id)}
+                    className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
+                      selectedId === project.id
+                        ? 'bg-bambu-green/20 text-bambu-green'
+                        : 'hover:bg-bambu-dark-tertiary text-white'
+                    }`}
+                  >
+                    <div
+                      className="w-3 h-3 rounded-full flex-shrink-0"
+                      style={{ backgroundColor: project.color || '#00ae42' }}
+                    />
+                    <span className="truncate">{project.name}</span>
+                  </button>
+                ))
+              ) : (
+                <p className="text-sm text-bambu-gray text-center py-4">No projects found</p>
+              )
+            ) : (
+              archives && archives.length > 0 ? (
+                archives.map((archive: Archive) => (
+                  <button
+                    key={archive.id}
+                    onClick={() => setSelectedId(archive.id)}
+                    className={`w-full text-left px-3 py-2 rounded transition-colors flex items-center gap-2 ${
+                      selectedId === archive.id
+                        ? 'bg-bambu-green/20 text-bambu-green'
+                        : 'hover:bg-bambu-dark-tertiary text-white'
+                    }`}
+                  >
+                    <FileBox className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                    <span className="truncate">{archive.print_name || archive.filename}</span>
+                  </button>
+                ))
+              ) : (
+                <p className="text-sm text-bambu-gray text-center py-4">No archives found</p>
+              )
+            )}
+          </div>
+        </div>
+
+        <div className="p-4 border-t border-bambu-dark-tertiary flex justify-between">
+          {isLinked && (
+            <Button variant="danger" onClick={handleUnlink} disabled={isLoading}>
+              <Unlink className="w-4 h-4 mr-2" />
+              Unlink
+            </Button>
+          )}
+          <div className={`flex gap-2 ${!isLinked ? 'ml-auto' : ''}`}>
+            <Button variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button onClick={handleSave} disabled={!selectedId || isLoading}>
+              {isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Link'}
+            </Button>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// 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<UploadFile[]>([]);
+  const [isDragging, setIsDragging] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(true);
+  };
+
+  const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(false);
+  };
+
+  const handleDrop = (e: DragEvent<HTMLDivElement>) => {
+    e.preventDefault();
+    setIsDragging(false);
+    const droppedFiles = Array.from(e.dataTransfer.files);
+    addFiles(droppedFiles);
+  };
+
+  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+    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 (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-lg border border-bambu-dark-tertiary">
+        <div className="p-4 border-b border-bambu-dark-tertiary flex items-center justify-between">
+          <h2 className="text-lg font-semibold text-white">Upload Files</h2>
+          <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
+            <X className="w-5 h-5 text-bambu-gray" />
+          </button>
+        </div>
+
+        <div className="p-4 space-y-4">
+          {/* Drop Zone */}
+          <div
+            onDragOver={handleDragOver}
+            onDragLeave={handleDragLeave}
+            onDrop={handleDrop}
+            onClick={() => 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'
+            }`}
+          >
+            <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragging ? 'text-bambu-green' : 'text-bambu-gray'}`} />
+            <p className="text-white font-medium">
+              {isDragging ? 'Drop files here' : 'Drag & drop files here'}
+            </p>
+            <p className="text-sm text-bambu-gray mt-1">or click to browse</p>
+          </div>
+
+          <input
+            ref={fileInputRef}
+            type="file"
+            multiple
+            className="hidden"
+            onChange={handleFileSelect}
+          />
+
+          {/* File List */}
+          {files.length > 0 && (
+            <div className="max-h-48 overflow-y-auto space-y-2">
+              {files.map((uploadFile, index) => (
+                <div
+                  key={index}
+                  className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
+                >
+                  <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                  <div className="flex-1 min-w-0">
+                    <p className="text-sm text-white truncate">{uploadFile.file.name}</p>
+                    <p className="text-xs text-bambu-gray">
+                      {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
+                    </p>
+                  </div>
+                  {uploadFile.status === 'pending' && (
+                    <button
+                      onClick={() => removeFile(index)}
+                      className="p-1 hover:bg-bambu-dark-tertiary rounded"
+                    >
+                      <X className="w-4 h-4 text-bambu-gray" />
+                    </button>
+                  )}
+                  {uploadFile.status === 'uploading' && (
+                    <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
+                  )}
+                  {uploadFile.status === 'success' && (
+                    <CheckCircle className="w-4 h-4 text-green-500" />
+                  )}
+                  {uploadFile.status === 'error' && (
+                    <span title={uploadFile.error}>
+                      <XCircle className="w-4 h-4 text-red-500" />
+                    </span>
+                  )}
+                </div>
+              ))}
+            </div>
+          )}
+
+          {/* Summary */}
+          {allDone && (
+            <div className="p-3 bg-bambu-dark rounded-lg">
+              <p className="text-sm text-white">
+                Upload complete: {successCount} succeeded
+                {errorCount > 0 && <span className="text-red-400">, {errorCount} failed</span>}
+              </p>
+            </div>
+          )}
+        </div>
+
+        <div className="p-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <Button variant="secondary" onClick={onClose}>
+            {allDone ? 'Close' : 'Cancel'}
+          </Button>
+          {!allDone && (
+            <Button
+              onClick={handleUpload}
+              disabled={pendingCount === 0 || isUploading}
+            >
+              {isUploading ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  Uploading...
+                </>
+              ) : (
+                <>
+                  <Upload className="w-4 h-4 mr-2" />
+                  Upload {pendingCount > 0 ? `(${pendingCount})` : ''}
+                </>
+              )}
+            </Button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// 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 (
+    <div>
+      <div
+        className={`group flex items-center gap-1 px-2 py-1.5 rounded cursor-pointer transition-colors ${
+          selectedFolderId === folder.id
+            ? 'bg-bambu-green/20 text-bambu-green'
+            : 'hover:bg-bambu-dark text-white'
+        }`}
+        style={{ paddingLeft: `${8 + depth * 12}px` }}
+        onClick={() => onSelect(folder.id)}
+      >
+        {hasChildren ? (
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              setExpanded(!expanded);
+            }}
+            className="p-0.5 hover:bg-bambu-dark-tertiary rounded"
+          >
+            <ChevronRight className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
+          </button>
+        ) : (
+          <div className="w-4.5" />
+        )}
+        <FolderOpen className="w-4 h-4 text-bambu-green flex-shrink-0" />
+        <span className="text-sm truncate flex-1">{folder.name}</span>
+        {/* Link indicator - clickable to change link */}
+        {isLinked && (
+          <button
+            onClick={(e) => { e.stopPropagation(); onLink(folder); }}
+            className="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
+            title={`${folder.project_name ? `Project: ${folder.project_name}` : `Archive: ${folder.archive_name}`} (click to change)`}
+          >
+            <Link2 className="w-3 h-3" />
+            {folder.project_name ? (
+              <Briefcase className="w-3 h-3" />
+            ) : (
+              <ArchiveIcon className="w-3 h-3" />
+            )}
+          </button>
+        )}
+        {folder.file_count > 0 && (
+          <span className="text-xs text-bambu-gray">{folder.file_count}</span>
+        )}
+        {/* Quick link button - always visible for unlinked folders */}
+        {!isLinked && (
+          <button
+            onClick={(e) => { e.stopPropagation(); onLink(folder); }}
+            className="p-1 rounded hover:bg-bambu-dark-tertiary"
+            title="Link to project or archive"
+          >
+            <Link2 className="w-3.5 h-3.5 text-bambu-gray hover:text-bambu-green" />
+          </button>
+        )}
+        <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
+          <div className="relative">
+            <button
+              onClick={() => setShowActions(!showActions)}
+              className="p-1 rounded hover:bg-bambu-dark-tertiary"
+            >
+              <MoreVertical className="w-3.5 h-3.5 text-bambu-gray" />
+            </button>
+            {showActions && (
+              <>
+                <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
+                <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onLink(folder); setShowActions(false); }}
+                >
+                  <Link2 className="w-3.5 h-3.5" />
+                  {isLinked ? 'Change Link...' : 'Link to...'}
+                </button>
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onDelete(folder.id); setShowActions(false); }}
+                >
+                  <Trash2 className="w-3.5 h-3.5" />
+                  Delete
+                </button>
+              </div>
+              </>
+            )}
+          </div>
+        </div>
+      </div>
+      {hasChildren && expanded && (
+        <div>
+          {folder.children.map((child) => (
+            <FolderTreeItem
+              key={child.id}
+              folder={child}
+              selectedFolderId={selectedFolderId}
+              onSelect={onSelect}
+              onDelete={onDelete}
+              onLink={onLink}
+              depth={depth + 1}
+            />
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 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 (
+    <div
+      className={`group relative bg-bambu-card rounded-lg border transition-all cursor-pointer overflow-hidden ${
+        isSelected
+          ? 'border-bambu-green ring-1 ring-bambu-green'
+          : 'border-bambu-dark-tertiary hover:border-bambu-green/50'
+      }`}
+      onClick={() => onSelect(file.id)}
+    >
+      {/* Thumbnail */}
+      <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
+        {file.thumbnail_path ? (
+          <img
+            src={api.getLibraryFileThumbnailUrl(file.id)}
+            alt={file.filename}
+            className="w-full h-full object-cover"
+          />
+        ) : (
+          <FileBox className="w-12 h-12 text-bambu-gray/30" />
+        )}
+        {/* Duplicate badge */}
+        {file.duplicate_count > 0 && (
+          <div className="absolute top-2 left-2 flex items-center gap-1 bg-amber-500/90 text-white text-xs px-1.5 py-0.5 rounded">
+            <Copy className="w-3 h-3" />
+            {file.duplicate_count}
+          </div>
+        )}
+        {/* File type badge */}
+        <div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${
+          file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'
+          : file.file_type === 'gcode' ? 'bg-blue-500/90 text-white'
+          : file.file_type === 'stl' ? 'bg-purple-500/90 text-white'
+          : 'bg-bambu-gray/90 text-white'
+        }`}>
+          {file.file_type.toUpperCase()}
+        </div>
+      </div>
+
+      {/* Info */}
+      <div className="p-3">
+        <h3 className="text-sm font-medium text-white truncate" title={file.print_name || file.filename}>
+          {file.print_name || file.filename}
+        </h3>
+        <div className="flex items-center gap-3 mt-1 text-xs text-bambu-gray">
+          <span>{formatFileSize(file.file_size)}</span>
+          {file.print_time_seconds && (
+            <span className="flex items-center gap-1">
+              <Clock className="w-3 h-3" />
+              {formatDuration(file.print_time_seconds)}
+            </span>
+          )}
+        </div>
+        {file.print_count > 0 && (
+          <div className="mt-1 text-xs text-bambu-green">
+            Printed {file.print_count}x
+          </div>
+        )}
+      </div>
+
+      {/* Actions */}
+      <div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
+        <button
+          onClick={() => setShowActions(!showActions)}
+          className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
+        >
+          <MoreVertical className="w-4 h-4 text-bambu-gray" />
+        </button>
+        {showActions && (
+          <>
+            <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
+            <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
+              {onAddToQueue && isSlicedFilename(file.filename) && (
+                <button
+                  className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
+                  onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
+                >
+                  <Printer className="w-3.5 h-3.5" />
+                  Add to Queue
+                </button>
+              )}
+              <button
+                className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
+                onClick={() => { onDownload(file.id); setShowActions(false); }}
+              >
+                <Download className="w-3.5 h-3.5" />
+                Download
+              </button>
+              <button
+                className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
+                onClick={() => { onDelete(file.id); setShowActions(false); }}
+              >
+                <Trash2 className="w-3.5 h-3.5" />
+                Delete
+              </button>
+            </div>
+          </>
+        )}
+      </div>
+
+      {/* Selection checkbox */}
+      <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
+        isSelected
+          ? 'bg-bambu-green border-bambu-green'
+          : 'border-white/30 bg-black/30 opacity-0 group-hover:opacity-100'
+      }`}>
+        {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
+      </div>
+    </div>
+  );
+}
+
+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<number | null>(initialFolderId);
+  const [selectedFiles, setSelectedFiles] = useState<number[]>([]);
+  const [showNewFolderModal, setShowNewFolderModal] = useState(false);
+  const [showMoveModal, setShowMoveModal] = useState(false);
+  const [showUploadModal, setShowUploadModal] = useState(false);
+  const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(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<string>('all');
+  const [sortField, setSortField] = useState<SortField>('date');
+  const [sortDirection, setSortDirection] = useState<SortDirection>('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<AppSettings>,
+  });
+  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 (
+    <div className="p-4 md:p-8 h-[calc(100vh-64px)] flex flex-col">
+      {/* Header */}
+      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
+        <div>
+          <h1 className="text-2xl font-bold text-white flex items-center gap-3">
+            <div className="p-2.5 bg-bambu-green/10 rounded-xl">
+              <FolderOpen className="w-6 h-6 text-bambu-green" />
+            </div>
+            File Manager
+          </h1>
+          <p className="text-sm text-bambu-gray mt-2 ml-14">
+            Organize and manage your print files
+          </p>
+        </div>
+        <div className="flex items-center gap-2">
+          {/* View mode toggle */}
+          <div className="flex items-center bg-bambu-dark rounded-lg p-1">
+            <button
+              onClick={() => handleViewModeChange('grid')}
+              className={`p-1.5 rounded transition-colors ${
+                viewMode === 'grid' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
+              }`}
+              title="Grid view"
+            >
+              <LayoutGrid className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => handleViewModeChange('list')}
+              className={`p-1.5 rounded transition-colors ${
+                viewMode === 'list' ? 'bg-bambu-card text-white' : 'text-bambu-gray hover:text-white'
+              }`}
+              title="List view"
+            >
+              <List className="w-4 h-4" />
+            </button>
+          </div>
+          <Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
+            <FolderPlus className="w-4 h-4 mr-2" />
+            New Folder
+          </Button>
+          <Button onClick={() => setShowUploadModal(true)}>
+            <Upload className="w-4 h-4 mr-2" />
+            Upload
+          </Button>
+        </div>
+      </div>
+
+      {/* Disk space warning */}
+      {isDiskSpaceLow && stats && settings && (
+        <div className="flex items-center gap-3 mb-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
+          <AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0" />
+          <div className="flex-1">
+            <p className="text-sm text-amber-500 font-medium">Low disk space warning</p>
+            <p className="text-xs text-amber-500/80">
+              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.
+            </p>
+          </div>
+        </div>
+      )}
+
+      {/* Stats bar */}
+      {stats && (
+        <div className="flex items-center gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2 text-sm">
+            <File className="w-4 h-4 text-bambu-green" />
+            <span className="text-bambu-gray">Files:</span>
+            <span className="text-white font-medium">{stats.total_files}</span>
+          </div>
+          <div className="flex items-center gap-2 text-sm">
+            <FolderOpen className="w-4 h-4 text-blue-400" />
+            <span className="text-bambu-gray">Folders:</span>
+            <span className="text-white font-medium">{stats.total_folders}</span>
+          </div>
+          <div className="flex items-center gap-2 text-sm">
+            <HardDrive className="w-4 h-4 text-amber-400" />
+            <span className="text-bambu-gray">Size:</span>
+            <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
+          </div>
+          <div className="flex items-center gap-2 text-sm ml-auto">
+            <span className="text-bambu-gray">Free:</span>
+            <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
+              {formatFileSize(stats.disk_free_bytes)}
+            </span>
+          </div>
+        </div>
+      )}
+
+      {/* Main content */}
+      <div className="flex-1 flex gap-6 min-h-0">
+        {/* Folder sidebar */}
+        <div className="w-64 flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col">
+          <div className="p-3 border-b border-bambu-dark-tertiary">
+            <h2 className="text-sm font-medium text-white">Folders</h2>
+          </div>
+          <div className="flex-1 overflow-y-auto p-2">
+            {/* All Files (root) */}
+            <div
+              className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors ${
+                selectedFolderId === null
+                  ? 'bg-bambu-green/20 text-bambu-green'
+                  : 'hover:bg-bambu-dark text-white'
+              }`}
+              onClick={() => setSelectedFolderId(null)}
+            >
+              <FileBox className="w-4 h-4" />
+              <span className="text-sm">All Files</span>
+            </div>
+
+            {/* Folder tree */}
+            {folders?.map((folder) => (
+              <FolderTreeItem
+                key={folder.id}
+                folder={folder}
+                selectedFolderId={selectedFolderId}
+                onSelect={setSelectedFolderId}
+                onDelete={(id) => setDeleteConfirm({ type: 'folder', id })}
+                onLink={setLinkFolder}
+              />
+            ))}
+          </div>
+        </div>
+
+        {/* Files area */}
+        <div className="flex-1 flex flex-col min-w-0">
+          {/* Search, Filter, Sort toolbar */}
+          {files && files.length > 0 && (
+            <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+              {/* Search */}
+              <div className="relative flex-1 max-w-xs">
+                <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                <input
+                  type="text"
+                  placeholder="Search files..."
+                  value={searchQuery}
+                  onChange={(e) => 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"
+                />
+              </div>
+
+              {/* Type filter */}
+              <div className="flex items-center gap-2">
+                <Filter className="w-4 h-4 text-bambu-gray" />
+                <select
+                  value={filterType}
+                  onChange={(e) => setFilterType(e.target.value)}
+                  className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
+                >
+                  <option value="all">All types</option>
+                  {fileTypes.map((type) => (
+                    <option key={type} value={type}>
+                      {type.toUpperCase()}
+                    </option>
+                  ))}
+                </select>
+              </div>
+
+              {/* Sort */}
+              <div className="flex items-center gap-2">
+                <select
+                  value={sortField}
+                  onChange={(e) => setSortField(e.target.value as SortField)}
+                  className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
+                >
+                  <option value="date">Date</option>
+                  <option value="name">Name</option>
+                  <option value="size">Size</option>
+                  <option value="type">Type</option>
+                  <option value="prints">Prints</option>
+                </select>
+                <button
+                  onClick={() => setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
+                  className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
+                  title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
+                >
+                  {sortDirection === 'asc' ? (
+                    <SortAsc className="w-4 h-4 text-white" />
+                  ) : (
+                    <SortDesc className="w-4 h-4 text-white" />
+                  )}
+                </button>
+              </div>
+
+              {/* Results count */}
+              {(searchQuery || filterType !== 'all') && (
+                <span className="text-sm text-bambu-gray">
+                  {filteredAndSortedFiles.length} of {files.length} files
+                </span>
+              )}
+            </div>
+          )}
+
+          {/* Selection toolbar */}
+          {filteredAndSortedFiles.length > 0 && (
+            <div className="flex items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+              {/* Select all / Deselect all */}
+              {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={handleDeselectAll}
+                >
+                  <Square className="w-4 h-4 mr-1" />
+                  Deselect All
+                </Button>
+              ) : (
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={handleSelectAll}
+                >
+                  <CheckSquare className="w-4 h-4 mr-1" />
+                  Select All
+                </Button>
+              )}
+
+              {selectedFiles.length > 0 && (
+                <>
+                  <span className="text-sm text-bambu-gray ml-2">
+                    {selectedFiles.length} selected
+                  </span>
+                  <div className="flex-1" />
+                  {selectedSlicedFiles.length > 0 && (
+                    <Button
+                      variant="primary"
+                      size="sm"
+                      onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
+                      disabled={addToQueueMutation.isPending}
+                    >
+                      <Printer className="w-4 h-4 mr-1" />
+                      {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
+                    </Button>
+                  )}
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => setShowMoveModal(true)}
+                  >
+                    <MoveRight className="w-4 h-4 mr-1" />
+                    Move
+                  </Button>
+                  <Button
+                    variant="danger"
+                    size="sm"
+                    onClick={() => {
+                      if (selectedFiles.length === 1) {
+                        setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
+                      } else {
+                        setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
+                      }
+                    }}
+                  >
+                    <Trash2 className="w-4 h-4 mr-1" />
+                    Delete
+                  </Button>
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={handleDeselectAll}
+                  >
+                    Clear
+                  </Button>
+                </>
+              )}
+            </div>
+          )}
+
+          {/* File grid/list */}
+          {isLoading ? (
+            <div className="flex-1 flex items-center justify-center">
+              <div className="flex flex-col items-center gap-3">
+                <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+                <p className="text-sm text-bambu-gray">Loading files...</p>
+              </div>
+            </div>
+          ) : files?.length === 0 ? (
+            <div className="flex-1 flex flex-col items-center justify-center">
+              <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
+                <FileBox className="w-12 h-12 text-bambu-gray/50" />
+              </div>
+              <h3 className="text-lg font-medium text-white mb-2">
+                {selectedFolderId !== null ? 'Folder is empty' : 'No files yet'}
+              </h3>
+              <p className="text-bambu-gray text-center max-w-md mb-6">
+                {selectedFolderId !== null
+                  ? 'Upload files or move files into this folder to get started.'
+                  : 'Upload files to start organizing your print-related files.'}
+              </p>
+              <Button onClick={() => setShowUploadModal(true)}>
+                <Plus className="w-4 h-4 mr-2" />
+                Upload Files
+              </Button>
+            </div>
+          ) : filteredAndSortedFiles.length === 0 ? (
+            <div className="flex-1 flex flex-col items-center justify-center">
+              <div className="p-4 bg-bambu-dark rounded-2xl mb-4">
+                <Search className="w-12 h-12 text-bambu-gray/50" />
+              </div>
+              <h3 className="text-lg font-medium text-white mb-2">No matching files</h3>
+              <p className="text-bambu-gray text-center max-w-md mb-6">
+                No files match your current search or filter criteria.
+              </p>
+              <Button variant="secondary" onClick={() => { setSearchQuery(''); setFilterType('all'); }}>
+                Clear filters
+              </Button>
+            </div>
+          ) : viewMode === 'grid' ? (
+            <div className="flex-1 overflow-y-auto">
+              <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
+                {filteredAndSortedFiles.map((file) => (
+                  <FileCard
+                    key={file.id}
+                    file={file}
+                    isSelected={selectedFiles.includes(file.id)}
+                    onSelect={handleFileSelect}
+                    onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
+                    onDownload={handleDownload}
+                    onAddToQueue={(id) => addToQueueMutation.mutate([id])}
+                  />
+                ))}
+              </div>
+            </div>
+          ) : (
+            <div className="flex-1 overflow-y-auto">
+              <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
+                {/* List header */}
+                <div className="grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
+                  <div className="w-6" />
+                  <div>Name</div>
+                  <div>Type</div>
+                  <div>Size</div>
+                  <div>Prints</div>
+                  <div />
+                </div>
+                {/* List rows */}
+                {filteredAndSortedFiles.map((file) => (
+                  <div
+                    key={file.id}
+                    className={`grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-3 items-center border-b border-bambu-dark-tertiary last:border-b-0 cursor-pointer hover:bg-bambu-dark/50 transition-colors ${
+                      selectedFiles.includes(file.id) ? 'bg-bambu-green/10' : ''
+                    }`}
+                    onClick={() => handleFileSelect(file.id)}
+                  >
+                    {/* Checkbox */}
+                    <div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
+                      selectedFiles.includes(file.id)
+                        ? 'bg-bambu-green border-bambu-green'
+                        : 'border-bambu-gray/50'
+                    }`}>
+                      {selectedFiles.includes(file.id) && <div className="w-2 h-2 bg-white rounded-sm" />}
+                    </div>
+                    {/* Name with thumbnail */}
+                    <div className="flex items-center gap-3 min-w-0">
+                      <div className="relative group/thumb">
+                        <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
+                          {file.thumbnail_path ? (
+                            <img
+                              src={api.getLibraryFileThumbnailUrl(file.id)}
+                              alt=""
+                              className="w-full h-full object-cover"
+                            />
+                          ) : (
+                            <div className="w-full h-full flex items-center justify-center">
+                              <FileBox className="w-5 h-5 text-bambu-gray/50" />
+                            </div>
+                          )}
+                        </div>
+                        {/* Hover preview */}
+                        {file.thumbnail_path && (
+                          <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
+                            <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
+                              <img
+                                src={api.getLibraryFileThumbnailUrl(file.id)}
+                                alt={file.filename}
+                                className="w-full h-full object-contain"
+                              />
+                            </div>
+                          </div>
+                        )}
+                      </div>
+                      <div className="min-w-0">
+                        <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
+                        {file.duplicate_count > 0 && (
+                          <div className="flex items-center gap-1 text-xs text-amber-400">
+                            <Copy className="w-3 h-3" />
+                            {file.duplicate_count} duplicate(s)
+                          </div>
+                        )}
+                      </div>
+                    </div>
+                    {/* Type */}
+                    <div>
+                      <span className={`text-xs px-1.5 py-0.5 rounded font-medium ${
+                        file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
+                        : file.file_type === 'gcode' ? 'bg-blue-500/20 text-blue-400'
+                        : file.file_type === 'stl' ? 'bg-purple-500/20 text-purple-400'
+                        : 'bg-bambu-gray/20 text-bambu-gray'
+                      }`}>
+                        {file.file_type.toUpperCase()}
+                      </span>
+                    </div>
+                    {/* Size */}
+                    <div className="text-sm text-bambu-gray">{formatFileSize(file.file_size)}</div>
+                    {/* Prints */}
+                    <div className="text-sm text-bambu-gray">{file.print_count > 0 ? `${file.print_count}x` : '-'}</div>
+                    {/* Actions */}
+                    <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
+                      {isSlicedFilename(file.filename) && (
+                        <button
+                          onClick={() => addToQueueMutation.mutate([file.id])}
+                          className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
+                          title="Add to Queue"
+                          disabled={addToQueueMutation.isPending}
+                        >
+                          <Printer className="w-4 h-4" />
+                        </button>
+                      )}
+                      <button
+                        onClick={() => handleDownload(file.id)}
+                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                        title="Download"
+                      >
+                        <Download className="w-4 h-4" />
+                      </button>
+                      <button
+                        onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
+                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
+                        title="Delete"
+                      >
+                        <Trash2 className="w-4 h-4" />
+                      </button>
+                    </div>
+                  </div>
+                ))}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Modals */}
+      {showNewFolderModal && (
+        <NewFolderModal
+          parentId={selectedFolderId}
+          onClose={() => setShowNewFolderModal(false)}
+          onSave={(data) => createFolderMutation.mutate(data)}
+          isLoading={createFolderMutation.isPending}
+        />
+      )}
+
+      {showMoveModal && folders && (
+        <MoveFilesModal
+          folders={folders}
+          selectedFiles={selectedFiles}
+          currentFolderId={selectedFolderId}
+          onClose={() => setShowMoveModal(false)}
+          onMove={(folderId) => moveFilesMutation.mutate({ fileIds: selectedFiles, folderId })}
+          isLoading={moveFilesMutation.isPending}
+        />
+      )}
+
+      {showUploadModal && (
+        <UploadModal
+          folderId={selectedFolderId}
+          onClose={() => setShowUploadModal(false)}
+          onUploadComplete={handleUploadComplete}
+        />
+      )}
+
+      {linkFolder && (
+        <LinkFolderModal
+          folder={linkFolder}
+          onClose={() => setLinkFolder(null)}
+          onLink={(data) => updateFolderMutation.mutate({ id: linkFolder.id, data })}
+          isLoading={updateFolderMutation.isPending}
+        />
+      )}
+
+      {deleteConfirm && (
+        <ConfirmModal
+          title={
+            deleteConfirm.type === 'folder'
+              ? 'Delete Folder'
+              : deleteConfirm.type === 'bulk'
+              ? `Delete ${deleteConfirm.count} Files`
+              : 'Delete File'
+          }
+          message={
+            deleteConfirm.type === 'folder'
+              ? 'Are you sure you want to delete this folder? All files inside will also be deleted.'
+              : deleteConfirm.type === 'bulk'
+              ? `Are you sure you want to delete ${deleteConfirm.count} selected files? This action cannot be undone.`
+              : 'Are you sure you want to delete this file?'
+          }
+          confirmText="Delete"
+          variant="danger"
+          onConfirm={handleDeleteConfirm}
+          onCancel={() => setDeleteConfirm(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 27 - 102
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<HTMLInputElement>(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<HTMLInputElement>) => {
-    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() {
         </CardContent>
       </Card>
 
-      {/* Attachments section */}
+      {/* Files section - linked folders from File Manager */}
       <Card>
         <CardContent className="p-4">
           <div className="flex items-center justify-between mb-3">
             <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-              <Paperclip className="w-5 h-5" />
-              Attachments ({project.attachments?.length || 0})
+              <FolderOpen className="w-5 h-5" />
+              Files
             </h2>
-            <div>
-              <input
-                ref={fileInputRef}
-                type="file"
-                onChange={handleFileSelect}
-                className="hidden"
-              />
-              <Button
-                variant="secondary"
-                size="sm"
-                onClick={() => fileInputRef.current?.click()}
-                disabled={uploadingAttachment}
-              >
-                {uploadingAttachment ? (
-                  <Loader2 className="w-4 h-4 animate-spin mr-1" />
-                ) : (
-                  <Upload className="w-4 h-4 mr-1" />
-                )}
-                Upload
-              </Button>
-            </div>
           </div>
 
           <p className="text-xs text-bambu-gray mb-3">
-            Upload any file: images (PNG, JPG), PDFs, STL files, or documents.
+            <Link to="/files" className="text-bambu-green hover:underline">
+              Link folders from the File Manager
+            </Link>
+            {' '}to this project for quick access.
           </p>
 
-          {project.attachments && project.attachments.length > 0 ? (
+          {linkedFolders && linkedFolders.length > 0 ? (
             <div className="space-y-2">
-              {project.attachments.map((attachment) => (
-                <div
-                  key={attachment.filename}
-                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
+              {linkedFolders.map((folder) => (
+                <Link
+                  key={folder.id}
+                  to={`/files?folder=${folder.id}`}
+                  className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
                 >
                   <div className="flex items-center gap-3 min-w-0">
-                    <File className="w-5 h-5 text-bambu-gray flex-shrink-0" />
+                    <FolderOpen className="w-5 h-5 text-bambu-green flex-shrink-0" />
                     <div className="min-w-0">
                       <p className="text-sm text-white truncate">
-                        {attachment.original_name}
+                        {folder.name}
                       </p>
                       <p className="text-xs text-bambu-gray">
-                        {formatFileSize(attachment.size)}
+                        {folder.file_count} file{folder.file_count !== 1 ? 's' : ''}
                       </p>
                     </div>
                   </div>
-                  <div className="flex items-center gap-1 flex-shrink-0">
-                    <a
-                      href={api.getProjectAttachmentUrl(projectId, attachment.filename)}
-                      download={attachment.original_name}
-                      className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-white"
-                      title="Download"
-                    >
-                      <Download className="w-4 h-4" />
-                    </a>
-                    <button
-                      onClick={() => handleDeleteAttachment(attachment.filename, attachment.original_name)}
-                      className="p-2 rounded hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray hover:text-red-400"
-                      title="Delete"
-                    >
-                      <Trash2 className="w-4 h-4" />
-                    </button>
-                  </div>
-                </div>
+                  <ChevronRight className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                </Link>
               ))}
             </div>
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
-              No attachments yet. Click Upload to add files.
+              No folders linked. Go to File Manager and link a folder to this project.
             </p>
           )}
         </CardContent>

+ 61 - 4
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() {
             </CardContent>
           </Card>
 
-          {/* Sidebar Links */}
-          <ExternalLinksSettings />
+          {/* File Manager Settings */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <FileText className="w-5 h-5 text-bambu-green" />
+                File Manager
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              {/* Archive Mode */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Create Archive Entry When Printing
+                </label>
+                <select
+                  value={localSettings.library_archive_mode ?? 'ask'}
+                  onChange={(e) => updateSetting('library_archive_mode', e.target.value as 'always' | 'never' | 'ask')}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="always">Always create archive entry</option>
+                  <option value="never">Never create archive entry</option>
+                  <option value="ask">Ask each time</option>
+                </select>
+                <p className="text-xs text-bambu-gray mt-1">
+                  When printing from File Manager, optionally create an archive entry
+                </p>
+              </div>
+
+              {/* Disk Space Warning Threshold */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Low Disk Space Warning
+                </label>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="number"
+                    min="0.5"
+                    max="100"
+                    step="0.5"
+                    value={localSettings.library_disk_warning_gb ?? 5}
+                    onChange={(e) => 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"
+                  />
+                  <span className="text-bambu-gray">GB</span>
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  Show warning when free disk space falls below this threshold
+                </p>
+              </div>
+            </CardContent>
+          </Card>
         </div>
 
-        {/* Right Column - Updates */}
+        {/* Third Column - Sidebar Links & Updates */}
         <div className="space-y-6 flex-1 lg:max-w-sm">
+          {/* Sidebar Links */}
+          <ExternalLinksSettings />
 
           <Card>
             <CardHeader>

+ 22 - 1
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)}%)
             </p>
           </div>
-          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+          <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
             <StatCard
               icon={Archive}
               label={t('system.archiveStorage', 'Archive Storage')}
@@ -467,6 +480,14 @@ export function SystemInfoPage() {
               label={t('system.databaseSize', 'Database Size')}
               value={systemInfo.storage.database_size_formatted}
             />
+            {libraryStats && (
+              <StatCard
+                icon={FolderOpen}
+                label={t('system.fileManagerStorage', 'File Manager')}
+                value={formatBytes(libraryStats.total_size_bytes)}
+                subValue={`${libraryStats.total_files} files, ${libraryStats.total_folders} folders`}
+              />
+            )}
           </div>
         </div>
       </Section>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BNfnoADT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CBKbW_8F.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DMQ1f41h.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Dzh7xD3q.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BNfnoADT.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Dzh7xD3q.css">
+    <script type="module" crossorigin src="/assets/index-CBKbW_8F.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DMQ1f41h.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff