Browse Source

Add project import/export with ZIP file support

Features:
- Export single project as ZIP containing project.json and all files
  from linked library folders
- Export all projects as JSON (metadata only) for bulk backup
- Import from ZIP (creates folders and extracts files) or JSON
- New /api/v1/projects/import/file endpoint for file uploads
- Frontend buttons on Projects page and Project Detail page
- BOM items are now fully editable (not just checkbox toggle)
maziggy 4 tháng trước cách đây
mục cha
commit
30537cfa54

+ 9 - 0
CHANGELOG.md

@@ -5,6 +5,15 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6] - 2026-01-24
 
 ### New Features
+- **Project Import/Export** - Export and import projects with full file support (Issue #152):
+  - Export single project as ZIP (includes project settings, BOM, and all files from linked library folders)
+  - Export all projects as JSON for metadata-only backup
+  - Import from ZIP (with files) or JSON (metadata only)
+  - Linked folders and files are automatically created on import
+  - Useful for sharing complete project bundles or migrating between instances
+- **BOM Item Editing** - Bill of Materials items are now fully editable:
+  - Edit name, quantity, price, URL, and remarks after creation
+  - Pencil icon on each BOM item to enter edit mode
 - **External Link for Archives** - Add custom external links to archives for non-MakerWorld sources (Issue #151):
   - Link archives to Printables, Thingiverse, or any other URL
   - Globe button opens external link when set, falls back to auto-detected MakerWorld URL

+ 1 - 0
README.md

@@ -100,6 +100,7 @@
 - Auto-detect parts count from 3MF files
 - Color-coded project badges
 - Bulk assign archives via multi-select toolbar
+- Import/Export projects as ZIP (includes files) or JSON
 
 </td>
 <td width="50%" valign="top">

+ 396 - 1
backend/app/api/routes/projects.py

@@ -1,18 +1,23 @@
+import io
+import json
 import logging
 import os
 import uuid
+import zipfile
 from datetime import datetime
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
-from fastapi.responses import FileResponse
+from fastapi.responses import FileResponse, StreamingResponse
 from sqlalchemy import case, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes.library import get_library_dir
 from backend.app.core.config import 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.models.project_bom import ProjectBOMItem
@@ -25,6 +30,7 @@ from backend.app.schemas.project import (
     BOMItemUpdate,
     ProjectChildPreview,
     ProjectCreate,
+    ProjectImport,
     ProjectListResponse,
     ProjectResponse,
     ProjectStats,
@@ -1322,3 +1328,392 @@ async def get_project_timeline(
     events.sort(key=lambda e: e.timestamp, reverse=True)
 
     return events[:limit]
+
+
+# ============ Phase 10: Import/Export Endpoints ============
+
+
+@router.get("/{project_id}/export")
+async def export_project(
+    project_id: int,
+    format: str = "zip",  # "zip" (with files) or "json" (metadata only)
+    db: AsyncSession = Depends(get_db),
+):
+    """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get BOM items
+    bom_result = await db.execute(
+        select(ProjectBOMItem).where(ProjectBOMItem.project_id == project_id).order_by(ProjectBOMItem.sort_order)
+    )
+    bom_items = bom_result.scalars().all()
+
+    bom_export = [
+        {
+            "name": item.name,
+            "quantity_needed": item.quantity_needed,
+            "quantity_acquired": item.quantity_acquired,
+            "unit_price": item.unit_price,
+            "sourcing_url": item.sourcing_url,
+            "stl_filename": item.stl_filename,
+            "remarks": item.remarks,
+        }
+        for item in bom_items
+    ]
+
+    # Get linked folders and their files
+    folders_result = await db.execute(
+        select(LibraryFolder).where(LibraryFolder.project_id == project_id).order_by(LibraryFolder.name)
+    )
+    linked_folders = folders_result.scalars().all()
+
+    folders_export = []
+    files_to_include = []  # (archive_path, zip_path)
+
+    for folder in linked_folders:
+        # Get files in this folder
+        files_result = await db.execute(
+            select(LibraryFile).where(LibraryFile.folder_id == folder.id).order_by(LibraryFile.filename)
+        )
+        files = files_result.scalars().all()
+
+        folder_files = []
+        for f in files:
+            folder_files.append(
+                {
+                    "filename": f.filename,
+                    "file_type": f.file_type,
+                    "notes": f.notes,
+                }
+            )
+            # Add file to include in ZIP
+            library_dir = get_library_dir()
+            file_path = library_dir / f.file_path
+            if file_path.exists():
+                zip_path = f"files/{folder.name}/{f.filename}"
+                files_to_include.append((file_path, zip_path))
+                # Also include thumbnail if exists
+                if f.thumbnail_path:
+                    thumb_path = library_dir / f.thumbnail_path
+                    if thumb_path.exists():
+                        thumb_zip_path = f"files/{folder.name}/.thumbnails/{f.filename}.png"
+                        files_to_include.append((thumb_path, thumb_zip_path))
+
+        folders_export.append(
+            {
+                "name": folder.name,
+                "files": folder_files,
+            }
+        )
+
+    # Build project JSON
+    project_data = {
+        "name": project.name,
+        "description": project.description,
+        "color": project.color,
+        "status": project.status,
+        "target_count": project.target_count,
+        "target_parts_count": project.target_parts_count,
+        "notes": project.notes,
+        "tags": project.tags,
+        "due_date": project.due_date.isoformat() if project.due_date else None,
+        "priority": project.priority,
+        "budget": project.budget,
+        "bom_items": bom_export,
+        "linked_folders": folders_export,
+    }
+
+    # Return JSON if requested (for bulk export)
+    if format == "json":
+        return project_data
+
+    # Create ZIP in memory
+    zip_buffer = io.BytesIO()
+    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+        # Add project.json
+        zf.writestr("project.json", json.dumps(project_data, indent=2))
+
+        # Add files
+        for file_path, zip_path in files_to_include:
+            zf.write(file_path, zip_path)
+
+    zip_buffer.seek(0)
+
+    # Generate filename
+    safe_name = "".join(c if c.isalnum() or c in "-_ " else "_" for c in project.name)
+    filename = f"{safe_name}_{datetime.now().strftime('%Y-%m-%d')}.zip"
+
+    return StreamingResponse(
+        zip_buffer,
+        media_type="application/zip",
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@router.post("/import", response_model=ProjectResponse)
+async def import_project(
+    data: ProjectImport,
+    db: AsyncSession = Depends(get_db),
+):
+    """Import a project with optional BOM items and linked folders."""
+    # Create the project
+    project = Project(
+        name=data.name,
+        description=data.description,
+        color=data.color,
+        status=data.status,
+        target_count=data.target_count,
+        target_parts_count=data.target_parts_count,
+        notes=data.notes,
+        tags=data.tags,
+        due_date=data.due_date,
+        priority=data.priority,
+        budget=data.budget,
+    )
+    db.add(project)
+    await db.flush()
+
+    # Create BOM items
+    for idx, bom_data in enumerate(data.bom_items):
+        bom_item = ProjectBOMItem(
+            project_id=project.id,
+            name=bom_data.name,
+            quantity_needed=bom_data.quantity_needed,
+            quantity_acquired=bom_data.quantity_acquired,
+            unit_price=bom_data.unit_price,
+            sourcing_url=bom_data.sourcing_url,
+            stl_filename=bom_data.stl_filename,
+            remarks=bom_data.remarks,
+            sort_order=idx,
+        )
+        db.add(bom_item)
+
+    # Create linked folders in library
+    for folder_data in data.linked_folders:
+        # Check if folder with this name already exists at root level
+        existing_result = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == folder_data.name,
+                LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing_result.scalar_one_or_none()
+
+        if existing_folder:
+            # Link existing folder to project
+            existing_folder.project_id = project.id
+        else:
+            # Create new folder linked to project
+            new_folder = LibraryFolder(
+                name=folder_data.name,
+                project_id=project.id,
+                is_external=False,
+                external_readonly=False,
+                external_show_hidden=False,
+            )
+            db.add(new_folder)
+
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+@router.post("/import/file", response_model=ProjectResponse)
+async def import_project_file(
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+):
+    """Import a project from a ZIP or JSON file."""
+    if not file.filename:
+        raise HTTPException(status_code=400, detail="No filename provided")
+
+    # Determine file type
+    filename_lower = file.filename.lower()
+    content = await file.read()
+
+    if filename_lower.endswith(".zip"):
+        # Extract project.json from ZIP
+        try:
+            with zipfile.ZipFile(io.BytesIO(content)) as zf:
+                if "project.json" not in zf.namelist():
+                    raise HTTPException(status_code=400, detail="ZIP must contain project.json")
+                project_json = zf.read("project.json")
+                data = json.loads(project_json)
+
+                # Get list of files in the ZIP
+                zip_files = {name: zf.read(name) for name in zf.namelist() if name.startswith("files/")}
+        except zipfile.BadZipFile:
+            raise HTTPException(status_code=400, detail="Invalid ZIP file")
+    elif filename_lower.endswith(".json"):
+        try:
+            data = json.loads(content)
+            zip_files = {}
+        except json.JSONDecodeError:
+            raise HTTPException(status_code=400, detail="Invalid JSON file")
+    else:
+        raise HTTPException(status_code=400, detail="File must be .zip or .json")
+
+    # Create the project
+    project = Project(
+        name=data.get("name", "Imported Project"),
+        description=data.get("description"),
+        color=data.get("color"),
+        status=data.get("status", "active"),
+        target_count=data.get("target_count"),
+        target_parts_count=data.get("target_parts_count"),
+        notes=data.get("notes"),
+        tags=data.get("tags"),
+        due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
+        priority=data.get("priority", 0),
+        budget=data.get("budget"),
+    )
+    db.add(project)
+    await db.flush()
+
+    # Create BOM items
+    for idx, bom_data in enumerate(data.get("bom_items", [])):
+        bom_item = ProjectBOMItem(
+            project_id=project.id,
+            name=bom_data.get("name", "Unnamed"),
+            quantity_needed=bom_data.get("quantity_needed", 1),
+            quantity_acquired=bom_data.get("quantity_acquired", 0),
+            unit_price=bom_data.get("unit_price"),
+            sourcing_url=bom_data.get("sourcing_url"),
+            stl_filename=bom_data.get("stl_filename"),
+            remarks=bom_data.get("remarks"),
+            sort_order=idx,
+        )
+        db.add(bom_item)
+
+    # Create linked folders and files
+    library_dir = get_library_dir()
+    for folder_data in data.get("linked_folders", []):
+        folder_name = folder_data.get("name")
+        if not folder_name:
+            continue
+
+        # Check if folder exists
+        existing_result = await db.execute(
+            select(LibraryFolder).where(
+                LibraryFolder.name == folder_name,
+                LibraryFolder.parent_id.is_(None),
+            )
+        )
+        existing_folder = existing_result.scalar_one_or_none()
+
+        if existing_folder:
+            # Link existing folder to project
+            existing_folder.project_id = project.id
+            folder = existing_folder
+        else:
+            # Create new folder
+            folder = LibraryFolder(
+                name=folder_name,
+                project_id=project.id,
+                is_external=False,
+                external_readonly=False,
+                external_show_hidden=False,
+            )
+            db.add(folder)
+            await db.flush()
+
+            # Create folder on disk
+            folder_path = library_dir / folder_name
+            folder_path.mkdir(parents=True, exist_ok=True)
+
+        # Import files for this folder from ZIP
+        folder_prefix = f"files/{folder_name}/"
+        for zip_path, file_content in zip_files.items():
+            if not zip_path.startswith(folder_prefix):
+                continue
+            if "/.thumbnails/" in zip_path:
+                continue  # Skip thumbnails, we'll regenerate them
+
+            relative_path = zip_path[len(folder_prefix) :]
+            if not relative_path:
+                continue
+
+            # Write file to disk
+            file_disk_path = library_dir / folder_name / relative_path
+            file_disk_path.parent.mkdir(parents=True, exist_ok=True)
+            file_disk_path.write_bytes(file_content)
+
+            # Determine file type
+            ext = Path(relative_path).suffix.lower()
+            if ext in [".stl", ".3mf", ".obj"]:
+                file_type = "model"
+            elif ext in [".gcode"]:
+                file_type = "gcode"
+            elif ext in [".jpg", ".jpeg", ".png", ".gif", ".webp"]:
+                file_type = "image"
+            else:
+                file_type = "other"
+
+            # Create library file record
+            lib_file = LibraryFile(
+                folder_id=folder.id,
+                filename=relative_path,
+                file_path=f"{folder_name}/{relative_path}",
+                file_type=file_type,
+                file_size=len(file_content),
+                is_external=False,
+            )
+            db.add(lib_file)
+
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        target_parts_count=project.target_parts_count,
+        notes=project.notes,
+        attachments=project.attachments,
+        tags=project.tags,
+        due_date=project.due_date,
+        priority=project.priority,
+        budget=project.budget,
+        is_template=project.is_template,
+        template_source_id=project.template_source_id,
+        parent_id=project.parent_id,
+        parent_name=None,
+        children=[],
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )

+ 10 - 1
backend/app/models/library.py

@@ -2,7 +2,7 @@
 
 from datetime import datetime
 
-from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -17,6 +17,12 @@ class LibraryFolder(Base):
     name: Mapped[str] = mapped_column(String(255))
     parent_id: Mapped[int | None] = mapped_column(ForeignKey("library_folders.id", ondelete="CASCADE"), nullable=True)
 
+    # External folder flags (for folders that point to external paths)
+    is_external: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_readonly: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_show_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
+    external_path: Mapped[str | None] = mapped_column(String(500), 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)
@@ -55,6 +61,9 @@ class LibraryFile(Base):
     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)
 
+    # External file flag
+    is_external: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # File info
     filename: Mapped[str] = mapped_column(String(255))  # Original filename
     file_path: Mapped[str] = mapped_column(String(500))  # Storage path

+ 55 - 0
backend/app/schemas/project.py

@@ -205,3 +205,58 @@ class TimelineEvent(BaseModel):
     title: str
     description: str | None = None
     metadata: dict | None = None  # Additional event-specific data
+
+
+# Phase 10: Import/Export Schemas
+class BOMItemExport(BaseModel):
+    """Schema for exporting a BOM item."""
+
+    name: str
+    quantity_needed: int
+    quantity_acquired: int
+    unit_price: float | None
+    sourcing_url: str | None
+    stl_filename: str | None
+    remarks: str | None
+
+
+class LinkedFolderExport(BaseModel):
+    """Schema for exporting a linked library folder."""
+
+    name: str
+
+
+class ProjectExport(BaseModel):
+    """Schema for exporting a project."""
+
+    name: str
+    description: str | None
+    color: str | None
+    status: str
+    target_count: int | None
+    target_parts_count: int | None
+    notes: str | None
+    tags: str | None
+    due_date: datetime | None
+    priority: str
+    budget: float | None
+    bom_items: list[BOMItemExport] = []
+    linked_folders: list[LinkedFolderExport] = []
+
+
+class ProjectImport(BaseModel):
+    """Schema for importing a project."""
+
+    name: str
+    description: str | None = None
+    color: str | None = None
+    status: str = "active"
+    target_count: int | None = None
+    target_parts_count: int | None = None
+    notes: str | None = None
+    tags: str | None = None
+    due_date: datetime | None = None
+    priority: str = "normal"
+    budget: float | None = None
+    bom_items: list[BOMItemExport] = []
+    linked_folders: list[LinkedFolderExport] = []

+ 327 - 0
backend/tests/integration/test_projects_api.py

@@ -297,3 +297,330 @@ class TestProjectArchivesAPI:
         # Project should have an archive count (may be 0)
         data = response.json()
         assert "name" in data
+
+
+class TestProjectExportImport:
+    """Tests for project export/import functionality."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+        _counter = [0]
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Export Test Project {counter}",
+                "description": "Test project for export",
+                "color": "#00FF00",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.fixture
+    async def bom_item_factory(self, db_session):
+        """Factory to create test BOM items."""
+
+        async def _create_bom_item(project_id: int, **kwargs):
+            from backend.app.models.project_bom import ProjectBOMItem
+
+            defaults = {
+                "project_id": project_id,
+                "name": "Test Part",
+                "quantity_needed": 1,
+                "quantity_acquired": 0,
+                "sort_order": 0,
+            }
+            defaults.update(kwargs)
+
+            item = ProjectBOMItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_bom_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
+        """Verify project export includes BOM items."""
+        project = await project_factory(
+            name="Export Me",
+            description="A test project",
+            target_count=10,
+            target_parts_count=50,
+            budget=100.0,
+        )
+
+        # Add BOM items
+        await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
+        await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
+
+        # Test JSON format export
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Export Me"
+        assert data["description"] == "A test project"
+        assert data["target_count"] == 10
+        assert data["target_parts_count"] == 50
+        assert data["budget"] == 100.0
+        assert len(data["bom_items"]) == 2
+
+        # Check BOM items
+        bom_names = [item["name"] for item in data["bom_items"]]
+        assert "M3x8 Screws" in bom_names
+        assert "Heat Inserts" in bom_names
+
+        # Test ZIP format export (default)
+        zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
+        assert zip_response.status_code == 200
+        assert zip_response.headers["content-type"] == "application/zip"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project(self, async_client: AsyncClient):
+        """Verify project can be imported with BOM items."""
+        import_data = {
+            "name": "Imported Project",
+            "description": "Imported from JSON",
+            "color": "#FF00FF",
+            "target_count": 5,
+            "target_parts_count": 25,
+            "budget": 50.0,
+            "bom_items": [
+                {
+                    "name": "PTFE Tubes",
+                    "quantity_needed": 4,
+                    "quantity_acquired": 0,
+                    "unit_price": 2.50,
+                    "sourcing_url": "https://example.com",
+                    "stl_filename": None,
+                    "remarks": "Need 4mm ID",
+                },
+            ],
+        }
+
+        response = await async_client.post("/api/v1/projects/import", json=import_data)
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Imported Project"
+        assert data["description"] == "Imported from JSON"
+        assert data["target_count"] == 5
+        assert data["target_parts_count"] == 25
+        assert data["budget"] == 50.0
+        assert data["id"] > 0  # Has a valid ID
+        # BOM stats should show 1 item imported
+        assert data["stats"]["bom_total_items"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify project export includes linked folders."""
+        from backend.app.models.library import LibraryFolder
+
+        project = await project_factory(name="Project With Folder")
+
+        # Create a linked folder
+        folder = LibraryFolder(name="Project Files", project_id=project.id)
+        db_session.add(folder)
+        await db_session.commit()
+
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
+        assert response.status_code == 200
+
+        data = response.json()
+        assert data["name"] == "Project With Folder"
+        assert len(data["linked_folders"]) == 1
+        assert data["linked_folders"][0]["name"] == "Project Files"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
+        """Verify project import accepts linked folders data."""
+        import_data = {
+            "name": "Imported With Folders",
+            "linked_folders": [
+                {"name": "STL Files"},
+                {"name": "Documentation"},
+            ],
+        }
+
+        # Import should succeed with linked_folders
+        response = await async_client.post("/api/v1/projects/import", json=import_data)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "Imported With Folders"
+        assert data["id"] > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_from_json_file(self, async_client: AsyncClient):
+        """Verify project can be imported from JSON file upload."""
+        import io
+        import json
+
+        project_data = {
+            "name": "File Uploaded Project",
+            "description": "Imported from JSON file",
+            "color": "#123456",
+        }
+
+        # Create a file-like object
+        file_content = json.dumps(project_data).encode()
+        files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
+
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "File Uploaded Project"
+        assert data["description"] == "Imported from JSON file"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_project_from_zip_file(self, async_client: AsyncClient):
+        """Verify project can be imported from ZIP file with files."""
+        import io
+        import json
+        import zipfile
+
+        project_data = {
+            "name": "ZIP Imported Project",
+            "description": "Imported from ZIP",
+            "linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
+        }
+
+        # Create a ZIP file in memory
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("project.json", json.dumps(project_data))
+            zf.writestr("files/TestFolder/test.txt", "Hello World")
+
+        zip_buffer.seek(0)
+        files = {"file": ("project.zip", zip_buffer, "application/zip")}
+
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 200
+        data = response.json()
+        assert data["name"] == "ZIP Imported Project"
+        assert data["description"] == "Imported from ZIP"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
+        """Verify ZIP export contains actual files from linked folders."""
+        import io
+        import json
+        import zipfile
+        from pathlib import Path
+
+        from backend.app.api.routes.library import get_library_dir
+        from backend.app.models.library import LibraryFile, LibraryFolder
+
+        project = await project_factory(name="Project With Files")
+
+        # Create a linked folder with is_external fields
+        folder = LibraryFolder(
+            name="TestExportFolder",
+            project_id=project.id,
+            is_external=False,
+            external_readonly=False,
+            external_show_hidden=False,
+        )
+        db_session.add(folder)
+        await db_session.flush()
+
+        # Create a test file on disk
+        library_dir = get_library_dir()
+        folder_path = library_dir / "TestExportFolder"
+        folder_path.mkdir(parents=True, exist_ok=True)
+        test_file_path = folder_path / "test_export.txt"
+        test_file_path.write_text("Export test content")
+
+        # Create library file record
+        lib_file = LibraryFile(
+            folder_id=folder.id,
+            filename="test_export.txt",
+            file_path="TestExportFolder/test_export.txt",
+            file_type="other",
+            file_size=19,
+            is_external=False,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+
+        # Export as ZIP
+        response = await async_client.get(f"/api/v1/projects/{project.id}/export")
+        assert response.status_code == 200
+        assert response.headers["content-type"] == "application/zip"
+
+        # Verify ZIP contents
+        zip_buffer = io.BytesIO(response.content)
+        with zipfile.ZipFile(zip_buffer, "r") as zf:
+            assert "project.json" in zf.namelist()
+            assert "files/TestExportFolder/test_export.txt" in zf.namelist()
+
+            # Verify file content
+            file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
+            assert file_content == "Export test content"
+
+            # Verify project.json
+            project_data = json.loads(zf.read("project.json"))
+            assert project_data["name"] == "Project With Files"
+
+        # Cleanup
+        test_file_path.unlink(missing_ok=True)
+        folder_path.rmdir()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_invalid_file_type(self, async_client: AsyncClient):
+        """Verify import rejects invalid file types."""
+        import io
+
+        files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "must be .zip or .json" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
+        """Verify import rejects ZIP without project.json."""
+        import io
+        import zipfile
+
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w") as zf:
+            zf.writestr("other.txt", "no project.json here")
+
+        zip_buffer.seek(0)
+        files = {"file": ("project.zip", zip_buffer, "application/zip")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "project.json" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_import_invalid_json(self, async_client: AsyncClient):
+        """Verify import rejects invalid JSON content."""
+        import io
+
+        files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
+        response = await async_client.post("/api/v1/projects/import/file", files=files)
+        assert response.status_code == 400
+        assert "Invalid JSON" in response.json()["detail"]

+ 56 - 0
frontend/src/api/client.ts

@@ -530,6 +530,53 @@ export interface BOMItemUpdate {
   remarks?: string;
 }
 
+// Project Export/Import Types
+export interface BOMItemExport {
+  name: string;
+  quantity_needed: number;
+  quantity_acquired: number;
+  unit_price: number | null;
+  sourcing_url: string | null;
+  stl_filename: string | null;
+  remarks: string | null;
+}
+
+export interface LinkedFolderExport {
+  name: string;
+}
+
+export interface ProjectExport {
+  name: string;
+  description: string | null;
+  color: string | null;
+  status: string;
+  target_count: number | null;
+  target_parts_count: number | null;
+  notes: string | null;
+  tags: string | null;
+  due_date: string | null;
+  priority: string;
+  budget: number | null;
+  bom_items: BOMItemExport[];
+  linked_folders: LinkedFolderExport[];
+}
+
+export interface ProjectImport {
+  name: string;
+  description?: string;
+  color?: string;
+  status?: string;
+  target_count?: number;
+  target_parts_count?: number;
+  notes?: string;
+  tags?: string;
+  due_date?: string;
+  priority?: string;
+  budget?: number;
+  bom_items?: BOMItemExport[];
+  linked_folders?: LinkedFolderExport[];
+}
+
 // Timeline Types
 export interface TimelineEvent {
   event_type: string;
@@ -2683,6 +2730,15 @@ export const api = {
   getProjectTimeline: (projectId: number, limit = 50) =>
     request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
 
+  // Project Export/Import
+  exportProjectJson: (projectId: number) =>
+    request<ProjectExport>(`/projects/${projectId}/export?format=json`),
+  importProject: (data: ProjectImport) =>
+    request<Project>('/projects/import', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
   // API Keys
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
   createAPIKey: (data: APIKeyCreate) =>

+ 202 - 66
frontend/src/pages/ProjectDetailPage.tsx

@@ -27,10 +27,12 @@ import {
   ExternalLink,
   ShoppingCart,
   FolderOpen,
+  Download,
+  Pencil,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
-import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
+import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
@@ -276,6 +278,12 @@ export function ProjectDetailPage() {
   const [newBomRemarks, setNewBomRemarks] = useState('');
   const [showBomForm, setShowBomForm] = useState(false);
   const [hideBomCompleted, setHideBomCompleted] = useState(false);
+  const [editingBomItem, setEditingBomItem] = useState<BOMItem | null>(null);
+  const [editBomName, setEditBomName] = useState('');
+  const [editBomQty, setEditBomQty] = useState(1);
+  const [editBomPrice, setEditBomPrice] = useState('');
+  const [editBomUrl, setEditBomUrl] = useState('');
+  const [editBomRemarks, setEditBomRemarks] = useState('');
 
   // Confirm modal state
   const [confirmModal, setConfirmModal] = useState<{
@@ -302,11 +310,12 @@ export function ProjectDetailPage() {
   });
 
   const updateBomMutation = useMutation({
-    mutationFn: ({ itemId, data }: { itemId: number; data: { quantity_acquired?: number } }) =>
+    mutationFn: ({ itemId, data }: { itemId: number; data: BOMItemUpdate }) =>
       api.updateBOMItem(projectId, itemId, data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
       queryClient.invalidateQueries({ queryKey: ['project', projectId] });
+      setEditingBomItem(null);
     },
     onError: (error: Error) => showToast(error.message, 'error'),
   });
@@ -353,6 +362,57 @@ export function ProjectDetailPage() {
     });
   };
 
+  const handleEditBomItem = (item: BOMItem) => {
+    setEditingBomItem(item);
+    setEditBomName(item.name);
+    setEditBomQty(item.quantity_needed);
+    setEditBomPrice(item.unit_price?.toString() || '');
+    setEditBomUrl(item.sourcing_url || '');
+    setEditBomRemarks(item.remarks || '');
+  };
+
+  const handleSaveBomEdit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!editingBomItem || !editBomName.trim()) return;
+    updateBomMutation.mutate({
+      itemId: editingBomItem.id,
+      data: {
+        name: editBomName.trim(),
+        quantity_needed: editBomQty,
+        unit_price: editBomPrice ? parseFloat(editBomPrice) : undefined,
+        sourcing_url: editBomUrl.trim() || undefined,
+        remarks: editBomRemarks.trim() || undefined,
+      },
+    });
+  };
+
+  const handleCancelBomEdit = () => {
+    setEditingBomItem(null);
+  };
+
+  const handleExportProject = async () => {
+    try {
+      // Fetch ZIP file directly
+      const response = await fetch(`/api/v1/projects/${projectId}/export`);
+      if (!response.ok) {
+        throw new Error('Export failed');
+      }
+      const blob = await response.blob();
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      // Get filename from Content-Disposition header or use default
+      const contentDisposition = response.headers.get('Content-Disposition');
+      const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
+      a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
+      a.click();
+      URL.revokeObjectURL(url);
+      showToast('Project exported', 'success');
+    } catch (error) {
+      showToast((error as Error).message, 'error');
+    }
+  };
+
   // Template handlers
   const createTemplateMutation = useMutation({
     mutationFn: () => api.createTemplateFromProject(projectId),
@@ -433,10 +493,16 @@ export function ProjectDetailPage() {
           </div>
           <StatusBadge status={project.status} />
         </div>
-        <Button onClick={() => setShowEditModal(true)}>
-          <Edit3 className="w-4 h-4 mr-2" />
-          Edit
-        </Button>
+        <div className="flex gap-2">
+          <Button variant="secondary" onClick={handleExportProject} title="Export project">
+            <Download className="w-4 h-4 mr-2" />
+            Export
+          </Button>
+          <Button onClick={() => setShowEditModal(true)}>
+            <Edit3 className="w-4 h-4 mr-2" />
+            Edit
+          </Button>
+        </div>
       </div>
 
       {/* Progress bars (if targets set) */}
@@ -903,70 +969,140 @@ export function ProjectDetailPage() {
                     item.is_complete ? 'bg-status-ok/10' : 'bg-bambu-dark'
                   }`}
                 >
-                  <div className="flex items-start gap-3">
-                    <button
-                      onClick={() => handleToggleAcquired(item)}
-                      disabled={updateBomMutation.isPending}
-                      className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
-                        item.is_complete
-                          ? 'bg-status-ok border-status-ok text-white'
-                          : 'border-bambu-gray hover:border-bambu-green'
-                      }`}
-                    >
-                      {item.is_complete && <CheckCircle className="w-3 h-3" />}
-                    </button>
-                    <div className="flex-1 min-w-0">
-                      <div className="flex items-center justify-between gap-2">
-                        <div className="flex items-center gap-2 min-w-0">
-                          <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
-                            {item.name}
-                            <span className="text-bambu-gray font-normal ml-2">
-                              x{item.quantity_needed}
-                            </span>
-                          </p>
-                          {item.unit_price !== null && (
-                            <span className="text-xs text-bambu-green whitespace-nowrap">
-                              {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
-                            </span>
+                  {editingBomItem?.id === item.id ? (
+                    // Edit form for this BOM item
+                    <form onSubmit={handleSaveBomEdit} className="space-y-3">
+                      <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+                        <input
+                          type="text"
+                          value={editBomName}
+                          onChange={(e) => setEditBomName(e.target.value)}
+                          className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                          placeholder="Part name"
+                          autoFocus
+                        />
+                        <div className="flex gap-2">
+                          <input
+                            type="number"
+                            value={editBomQty}
+                            onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
+                            className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
+                            min="1"
+                            placeholder="Qty"
+                          />
+                          <input
+                            type="number"
+                            step="0.01"
+                            value={editBomPrice}
+                            onChange={(e) => setEditBomPrice(e.target.value)}
+                            className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                            placeholder={`Price (${currency})`}
+                          />
+                        </div>
+                      </div>
+                      <input
+                        type="url"
+                        value={editBomUrl}
+                        onChange={(e) => setEditBomUrl(e.target.value)}
+                        className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                        placeholder="Sourcing URL (optional)"
+                      />
+                      <input
+                        type="text"
+                        value={editBomRemarks}
+                        onChange={(e) => setEditBomRemarks(e.target.value)}
+                        className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+                        placeholder="Remarks (optional)"
+                      />
+                      <div className="flex justify-end gap-2">
+                        <Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
+                          Cancel
+                        </Button>
+                        <Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
+                          {updateBomMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            'Save'
                           )}
+                        </Button>
+                      </div>
+                    </form>
+                  ) : (
+                    // Display mode
+                    <div className="flex items-start gap-3">
+                      <button
+                        onClick={() => handleToggleAcquired(item)}
+                        disabled={updateBomMutation.isPending}
+                        className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
+                          item.is_complete
+                            ? 'bg-status-ok border-status-ok text-white'
+                            : 'border-bambu-gray hover:border-bambu-green'
+                        }`}
+                      >
+                        {item.is_complete && <CheckCircle className="w-3 h-3" />}
+                      </button>
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center justify-between gap-2">
+                          <div className="flex items-center gap-2 min-w-0">
+                            <p className={`text-sm font-medium ${item.is_complete ? 'text-bambu-gray line-through' : 'text-white'}`}>
+                              {item.name}
+                              <span className="text-bambu-gray font-normal ml-2">
+                                x{item.quantity_needed}
+                              </span>
+                            </p>
+                            {item.unit_price !== null && (
+                              <span className="text-xs text-bambu-green whitespace-nowrap">
+                                {currency}{(item.unit_price * item.quantity_needed).toFixed(2)}
+                              </span>
+                            )}
+                          </div>
+                          <div className="flex items-center gap-1">
+                            <button
+                              onClick={() => handleEditBomItem(item)}
+                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
+                              title="Edit"
+                            >
+                              <Pencil className="w-4 h-4" />
+                            </button>
+                            <button
+                              onClick={() => handleDeleteBomItem(item.id, item.name)}
+                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
+                              title="Delete"
+                            >
+                              <Trash2 className="w-4 h-4" />
+                            </button>
+                          </div>
                         </div>
-                        <button
-                          onClick={() => handleDeleteBomItem(item.id, item.name)}
-                          className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
-                          title="Delete"
-                        >
-                          <Trash2 className="w-4 h-4" />
-                        </button>
+                        {/* Sourcing URL */}
+                        {item.sourcing_url && (
+                          <a
+                            href={item.sourcing_url}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
+                            onClick={(e) => e.stopPropagation()}
+                          >
+                            <ExternalLink className="w-3 h-3 flex-shrink-0" />
+                            <span className="truncate">
+                              {(() => {
+                                try {
+                                  return new URL(item.sourcing_url).hostname.replace('www.', '');
+                                } catch {
+                                  return item.sourcing_url;
+                                }
+                              })()}
+                            </span>
+                          </a>
+                        )}
+                        {/* Remarks */}
+                        {item.remarks && (
+                          <p className="mt-1 text-xs text-bambu-gray/80 italic">
+                            {item.remarks}
+                          </p>
+                        )}
                       </div>
-                      {/* Sourcing URL */}
-                      {item.sourcing_url && (
-                        <a
-                          href={item.sourcing_url}
-                          target="_blank"
-                          rel="noopener noreferrer"
-                          className="flex items-center gap-1 mt-1 text-xs text-blue-400 hover:text-blue-300 transition-colors"
-                          onClick={(e) => e.stopPropagation()}
-                        >
-                          <ExternalLink className="w-3 h-3 flex-shrink-0" />
-                          <span className="truncate">
-                            {(() => {
-                              try {
-                                return new URL(item.sourcing_url).hostname.replace('www.', '');
-                              } catch {
-                                return item.sourcing_url;
-                              }
-                            })()}
-                          </span>
-                        </a>
-                      )}
-                      {/* Remarks */}
-                      {item.remarks && (
-                        <p className="mt-1 text-xs text-bambu-gray/80 italic">
-                          {item.remarks}
-                        </p>
-                      )}
                     </div>
-                  </div>
+                  )}
                 </div>
               ))}
               {/* BOM Total */}

+ 115 - 6
frontend/src/pages/ProjectsPage.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
@@ -16,9 +16,11 @@ import {
   AlertTriangle,
   ChevronRight,
   MoreVertical,
+  Download,
+  Upload,
 } from 'lucide-react';
 import { api } from '../api/client';
-import type { ProjectListItem, ProjectCreate, ProjectUpdate } from '../api/client';
+import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport } from '../api/client';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
@@ -613,6 +615,94 @@ export function ProjectsPage() {
     },
   });
 
+  const importMutation = useMutation({
+    mutationFn: (data: ProjectImport) => api.importProject(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+      showToast('Project imported', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  const handleExportAll = async () => {
+    try {
+      // Export all projects as JSON (metadata only, no files)
+      const allProjects = await api.getProjects();
+      const exports = await Promise.all(
+        allProjects.map(async (p) => {
+          const exported = await api.exportProjectJson(p.id);
+          return exported;
+        })
+      );
+      const blob = new Blob([JSON.stringify(exports, null, 2)], { type: 'application/json' });
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
+      a.click();
+      URL.revokeObjectURL(url);
+      showToast('Projects exported (metadata only)', 'success');
+    } catch (error) {
+      showToast((error as Error).message, 'error');
+    }
+  };
+
+  const handleImportClick = () => {
+    fileInputRef.current?.click();
+  };
+
+  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    try {
+      const filename = file.name.toLowerCase();
+
+      if (filename.endsWith('.zip')) {
+        // ZIP file: upload via file endpoint
+        const formData = new FormData();
+        formData.append('file', file);
+
+        const response = await fetch('/api/v1/projects/import/file', {
+          method: 'POST',
+          body: formData,
+        });
+
+        if (!response.ok) {
+          const errorData = await response.json();
+          throw new Error(errorData.detail || 'Import failed');
+        }
+
+        queryClient.invalidateQueries({ queryKey: ['projects'] });
+        showToast('Project imported', 'success');
+      } else {
+        // JSON file: parse and handle bulk or single import
+        const text = await file.text();
+        const data = JSON.parse(text);
+
+        // Handle both single project and array of projects
+        const projectsToImport = Array.isArray(data) ? data : [data];
+
+        for (const project of projectsToImport) {
+          await importMutation.mutateAsync(project);
+        }
+
+        if (projectsToImport.length > 1) {
+          showToast(`${projectsToImport.length} projects imported`, 'success');
+        }
+      }
+    } catch (error) {
+      showToast(`Import failed: ${(error as Error).message}`, 'error');
+    }
+
+    // Reset file input
+    e.target.value = '';
+  };
+
   const handleSave = (data: ProjectCreate | ProjectUpdate) => {
     if (editingProject) {
       updateMutation.mutate({ id: editingProject.id, data });
@@ -650,6 +740,15 @@ export function ProjectsPage() {
 
   return (
     <div className="p-4 md:p-8 space-y-8">
+      {/* Hidden file input for import */}
+      <input
+        ref={fileInputRef}
+        type="file"
+        accept=".json,.zip"
+        onChange={handleFileChange}
+        className="hidden"
+      />
+
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
         <div>
@@ -663,10 +762,20 @@ export function ProjectsPage() {
             Organize and track your 3D printing projects
           </p>
         </div>
-        <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
-          <Plus className="w-4 h-4 mr-2" />
-          New Project
-        </Button>
+        <div className="flex gap-2">
+          <Button variant="secondary" onClick={handleImportClick} title="Import project">
+            <Upload className="w-4 h-4 mr-2" />
+            Import
+          </Button>
+          <Button variant="secondary" onClick={handleExportAll} title="Export all projects">
+            <Download className="w-4 h-4 mr-2" />
+            Export
+          </Button>
+          <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
+            <Plus className="w-4 h-4 mr-2" />
+            New Project
+          </Button>
+        </div>
       </div>
 
       {/* Filter tabs */}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-CTmc90ua.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BQzscOA-.js"></script>
+    <script type="module" crossorigin src="/assets/index-CTmc90ua.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BKSrBx0A.css">
   </head>
   <body>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác