Browse Source

Added project page viewer and editor

Martin Ziegler 6 months ago
parent
commit
53c94deade

+ 10 - 0
README.md

@@ -33,6 +33,7 @@
 - **Filament Cost Tracking** - Track costs per print with customizable filament database
 - **Photo Attachments** - Attach photos to archived prints for documentation
 - **Failure Analysis** - Document failed prints with notes and photos
+- **Project Page Editor** - View and edit embedded MakerWorld project pages with images, descriptions, and designer info
 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
 - **File Manager** - Browse and manage files on your printer's SD card
 - **Re-print** - Send archived prints back to any connected printer
@@ -309,6 +310,15 @@ Prints are automatically archived when they complete. You can also:
 - Re-print any archived 3MF to a connected printer
 - Export archives for backup
 
+### Project Page
+
+3MF files downloaded from MakerWorld contain embedded project pages with model information. To view:
+1. Right-click any archive in the Archives page
+2. Select "Project Page" from the context menu
+3. View title, description, designer info, license, and images
+4. Click "Edit" to modify the project page metadata
+5. Changes are saved directly to the 3MF file
+
 ## Tech Stack
 
 - **Backend**: Python / FastAPI

+ 86 - 0
backend/app/api/routes/archives.py

@@ -888,3 +888,89 @@ async def reprint_archive(
         "archive_id": archive_id,
         "filename": archive.filename,
     }
+
+
+# =============================================================================
+# Project Page API
+# =============================================================================
+
+@router.get("/{archive_id}/project-page")
+async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
+    """Get the project page data from the 3MF file."""
+    from backend.app.services.archive import ProjectPageParser
+    from backend.app.schemas.archive import ProjectPageResponse
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "Archive file not found")
+
+    parser = ProjectPageParser(file_path)
+    data = parser.parse(archive_id)
+
+    return ProjectPageResponse(**data)
+
+
+@router.patch("/{archive_id}/project-page")
+async def update_project_page(
+    archive_id: int,
+    update_data: dict,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update project page metadata in the 3MF file."""
+    from backend.app.services.archive import ProjectPageParser
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "Archive file not found")
+
+    parser = ProjectPageParser(file_path)
+    success = parser.update_metadata(update_data)
+
+    if not success:
+        raise HTTPException(500, "Failed to update project page")
+
+    # Return updated data
+    data = parser.parse(archive_id)
+    return data
+
+
+@router.get("/{archive_id}/project-image/{image_path:path}")
+async def get_project_image(
+    archive_id: int,
+    image_path: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get an image from the 3MF project page."""
+    from backend.app.services.archive import ProjectPageParser
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "Archive file not found")
+
+    parser = ProjectPageParser(file_path)
+    result = parser.get_image(image_path)
+
+    if not result:
+        raise HTTPException(404, "Image not found in 3MF file")
+
+    image_data, content_type = result
+    return Response(
+        content=image_data,
+        media_type=content_type,
+        headers={"Cache-Control": "max-age=3600"},
+    )

+ 49 - 0
backend/app/schemas/archive.py

@@ -65,3 +65,52 @@ class ArchiveStats(BaseModel):
     total_cost: float
     prints_by_filament_type: dict
     prints_by_printer: dict
+
+
+class ProjectPageImage(BaseModel):
+    """Image embedded in 3MF project page."""
+    name: str
+    path: str  # Path within 3MF
+    url: str  # API URL to fetch image
+
+
+class ProjectPageResponse(BaseModel):
+    """Project page data extracted from 3MF file."""
+    # Model info
+    title: str | None = None
+    description: str | None = None  # HTML content
+    designer: str | None = None
+    designer_user_id: str | None = None
+    license: str | None = None
+    copyright: str | None = None
+    creation_date: str | None = None
+    modification_date: str | None = None
+    origin: str | None = None  # "original" or "remix"
+
+    # Profile info
+    profile_title: str | None = None
+    profile_description: str | None = None
+    profile_cover: str | None = None
+    profile_user_id: str | None = None
+    profile_user_name: str | None = None
+
+    # MakerWorld info
+    design_model_id: str | None = None
+    design_profile_id: str | None = None
+    design_region: str | None = None
+
+    # Images
+    model_pictures: list[ProjectPageImage] = []
+    profile_pictures: list[ProjectPageImage] = []
+    thumbnails: list[ProjectPageImage] = []
+
+
+class ProjectPageUpdate(BaseModel):
+    """Update project page data in 3MF file."""
+    title: str | None = None
+    description: str | None = None
+    designer: str | None = None
+    license: str | None = None
+    copyright: str | None = None
+    profile_title: str | None = None
+    profile_description: str | None = None

+ 200 - 0
backend/app/services/archive.py

@@ -264,6 +264,206 @@ class ThreeMFParser:
                 break
 
 
+class ProjectPageParser:
+    """Parser for extracting project page data from Bambu Lab 3MF files."""
+
+    def __init__(self, file_path: Path):
+        self.file_path = file_path
+
+    def parse(self, archive_id: int) -> dict:
+        """Extract project page metadata and images from 3MF file."""
+        import html
+        import re
+
+        result = {
+            "title": None,
+            "description": None,
+            "designer": None,
+            "designer_user_id": None,
+            "license": None,
+            "copyright": None,
+            "creation_date": None,
+            "modification_date": None,
+            "origin": None,
+            "profile_title": None,
+            "profile_description": None,
+            "profile_cover": None,
+            "profile_user_id": None,
+            "profile_user_name": None,
+            "design_model_id": None,
+            "design_profile_id": None,
+            "design_region": None,
+            "model_pictures": [],
+            "profile_pictures": [],
+            "thumbnails": [],
+        }
+
+        try:
+            with zipfile.ZipFile(self.file_path, "r") as zf:
+                # Parse 3D/3dmodel.model for metadata
+                model_path = "3D/3dmodel.model"
+                if model_path in zf.namelist():
+                    content = zf.read(model_path).decode("utf-8", errors="ignore")
+
+                    # Extract metadata elements using regex
+                    # Format: <metadata name="Key">Value</metadata> or <metadata name="Key" />
+                    metadata_pattern = r'<metadata\s+name="([^"]+)"[^>]*>([^<]*)</metadata>'
+                    matches = re.findall(metadata_pattern, content)
+
+                    field_mapping = {
+                        "Title": "title",
+                        "Description": "description",
+                        "Designer": "designer",
+                        "DesignerUserId": "designer_user_id",
+                        "License": "license",
+                        "Copyright": "copyright",
+                        "CreationDate": "creation_date",
+                        "ModificationDate": "modification_date",
+                        "Origin": "origin",
+                        "ProfileTitle": "profile_title",
+                        "ProfileDescription": "profile_description",
+                        "ProfileCover": "profile_cover",
+                        "ProfileUserId": "profile_user_id",
+                        "ProfileUserName": "profile_user_name",
+                        "DesignModelId": "design_model_id",
+                        "DesignProfileId": "design_profile_id",
+                        "DesignRegion": "design_region",
+                    }
+
+                    for name, value in matches:
+                        if name in field_mapping:
+                            # Decode HTML entities multiple times (content is often triple-encoded)
+                            decoded = value.strip()
+                            prev = None
+                            while prev != decoded:
+                                prev = decoded
+                                decoded = html.unescape(decoded)
+                            # Normalize non-breaking spaces to regular spaces
+                            decoded = decoded.replace('\xa0', ' ')
+                            result[field_mapping[name]] = decoded if decoded else None
+
+                # List images in Auxiliaries folder
+                from urllib.parse import quote
+                for name in zf.namelist():
+                    if name.startswith("Auxiliaries/Model Pictures/"):
+                        filename = name.split("/")[-1]
+                        if filename:
+                            result["model_pictures"].append({
+                                "name": filename,
+                                "path": name,
+                                "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
+                            })
+                    elif name.startswith("Auxiliaries/Profile Pictures/"):
+                        filename = name.split("/")[-1]
+                        if filename:
+                            result["profile_pictures"].append({
+                                "name": filename,
+                                "path": name,
+                                "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
+                            })
+                    elif name.startswith("Auxiliaries/.thumbnails/"):
+                        filename = name.split("/")[-1]
+                        if filename:
+                            result["thumbnails"].append({
+                                "name": filename,
+                                "path": name,
+                                "url": f"/api/v1/archives/{archive_id}/project-image/{quote(name, safe='')}",
+                            })
+
+        except Exception as e:
+            result["_error"] = str(e)
+
+        return result
+
+    def get_image(self, image_path: str) -> tuple[bytes, str] | None:
+        """Extract an image from the 3MF file.
+
+        Returns tuple of (image_data, content_type) or None if not found.
+        """
+        try:
+            with zipfile.ZipFile(self.file_path, "r") as zf:
+                if image_path in zf.namelist():
+                    data = zf.read(image_path)
+                    # Determine content type from extension
+                    ext = image_path.lower().split(".")[-1]
+                    content_types = {
+                        "png": "image/png",
+                        "jpg": "image/jpeg",
+                        "jpeg": "image/jpeg",
+                        "webp": "image/webp",
+                        "gif": "image/gif",
+                    }
+                    content_type = content_types.get(ext, "application/octet-stream")
+                    return (data, content_type)
+        except Exception:
+            pass
+        return None
+
+    def update_metadata(self, updates: dict) -> bool:
+        """Update project page metadata in the 3MF file.
+
+        Args:
+            updates: Dict with fields to update (title, description, designer, etc.)
+
+        Returns:
+            True if successful, False otherwise.
+        """
+        import html
+        import re
+        import tempfile
+
+        try:
+            # Read the 3MF file
+            with zipfile.ZipFile(self.file_path, "r") as zf_read:
+                # Find and read the 3dmodel.model file
+                model_path = "3D/3dmodel.model"
+                if model_path not in zf_read.namelist():
+                    return False
+
+                content = zf_read.read(model_path).decode("utf-8")
+
+                # Update metadata fields
+                field_mapping = {
+                    "title": "Title",
+                    "description": "Description",
+                    "designer": "Designer",
+                    "license": "License",
+                    "copyright": "Copyright",
+                    "profile_title": "ProfileTitle",
+                    "profile_description": "ProfileDescription",
+                }
+
+                for field, xml_name in field_mapping.items():
+                    if field in updates and updates[field] is not None:
+                        new_value = html.escape(updates[field])
+                        # Replace existing metadata or we'd need to add it
+                        pattern = rf'(<metadata\s+name="{xml_name}"[^>]*>)[^<]*(</metadata>)'
+                        replacement = rf'\g<1>{new_value}\g<2>'
+                        content = re.sub(pattern, replacement, content)
+
+                # Write to a temporary file first
+                with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
+                    tmp_path = Path(tmp.name)
+
+                # Create new zip with updated content
+                with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
+                    for item in zf_read.namelist():
+                        if item == model_path:
+                            zf_write.writestr(item, content.encode("utf-8"))
+                        else:
+                            zf_write.writestr(item, zf_read.read(item))
+
+            # Replace original file with updated one
+            shutil.move(tmp_path, self.file_path)
+            return True
+
+        except Exception:
+            # Clean up temp file if it exists
+            if "tmp_path" in locals() and tmp_path.exists():
+                tmp_path.unlink()
+            return False
+
+
 class ArchiveService:
     """Service for archiving print jobs."""
 

+ 832 - 6
frontend/package-lock.json

@@ -12,6 +12,14 @@
         "@dnd-kit/sortable": "^10.0.0",
         "@dnd-kit/utilities": "^3.2.2",
         "@tanstack/react-query": "^5.90.11",
+        "@tiptap/extension-color": "^3.11.1",
+        "@tiptap/extension-image": "^3.11.1",
+        "@tiptap/extension-link": "^3.11.1",
+        "@tiptap/extension-text-align": "^3.11.1",
+        "@tiptap/extension-text-style": "^3.11.1",
+        "@tiptap/extension-underline": "^3.11.1",
+        "@tiptap/react": "^3.11.1",
+        "@tiptap/starter-kit": "^3.11.1",
         "@types/three": "^0.181.0",
         "gcode-preview": "^2.18.0",
         "jszip": "^3.10.1",
@@ -996,6 +1004,23 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.3",
+      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+      "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+      "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1134,6 +1159,12 @@
         "url": "https://opencollective.com/immer"
       }
     },
+    "node_modules/@remirror/core-constants": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+      "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+      "license": "MIT"
+    },
     "node_modules/@rolldown/pluginutils": {
       "version": "1.0.0-beta.47",
       "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -1758,6 +1789,497 @@
         "react": "^18 || ^19"
       }
     },
+    "node_modules/@tiptap/core": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz",
+      "integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-blockquote": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.11.1.tgz",
+      "integrity": "sha512-c3DN5c/9kl8w1wCcylH9XqW0OyCegqE3EL4rDlVYkyBD0GwCnUS30pN+jdxCUq/tl94lkkRk7XMyEUwzQmG+5g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-bold": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.11.1.tgz",
+      "integrity": "sha512-ee/OoAPViUAgJb8dxF7D2YSSYUWcw8RXqhNSDx15w58rxpYbJbvOv3WDMrGNvl4M9nuwXYfXc3iPl/eYtwHx2w==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-bubble-menu": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.11.1.tgz",
+      "integrity": "sha512-rLgU2drvoSTpdXEmoo61ZSmtRR44vMeS36OoDpUA1dNzo/vWAiOzQeLnm8gC9cD2TmvJ+WIe7tOkpAEfw4kmiQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@floating-ui/dom": "^1.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-bullet-list": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.11.1.tgz",
+      "integrity": "sha512-6fj0b0Ynam8FMsP3NiCZ4a2uP7lCBHDFBXfcRwFDOqAgBIPvIK+r6CuHEGothGaF7EeQ9MTyj9fwlGjyHsPQcg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-code": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.11.1.tgz",
+      "integrity": "sha512-R3HtNPAuaKqbDwK1uWO/6QFHXbbKcxbV27XVCVtTQ4gCAzIZbJElp9REEqAOp/zI6bqt774UrAekeV+5i8NQYw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-code-block": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.11.1.tgz",
+      "integrity": "sha512-Bk7mmA+m510zzLG5AMFmywrL50NlBA5p7bR0cKfdp4ckXr8FohxH3QS0Woy1MRnFUGRtIzJkSYQTJ3O/G1lBqQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-color": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.11.1.tgz",
+      "integrity": "sha512-ZKK0ZbqlpmYgtRBJcSxDPtMgmPQBoQv7I7xTMF1+E4DbwSgU7HPUkGLDOcc/ezmWfZLyMKXgKmvy+54nuut4vg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-text-style": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-document": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.11.1.tgz",
+      "integrity": "sha512-Px8T7Kv8EEiFpM/h13Rro8HoynrlK8zA3u3ekHq/FBSTXnPtqPAUYNx/DUhIrLs3eWWJ8+P0Onm+sVLZmaLMug==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-dropcursor": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.11.1.tgz",
+      "integrity": "sha512-+tmWD/4tg7Mt1TArrvc1Gna1FiSyru2rE6sapEerXCH3RFfaqGBeMqeRaOeZrCiqB+vIsXfthHDC/7xz5rFp/g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-floating-menu": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.11.1.tgz",
+      "integrity": "sha512-HGF04KhDn1oAD+2/zStSWeGIgR41l/raf64h/Rwkvde5Sf2g3BPRW4M1ESS6e2Rjw74Kwa4/xNO6CzZNid6yRg==",
+      "license": "MIT",
+      "optional": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@floating-ui/dom": "^1.0.0",
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-gapcursor": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.11.1.tgz",
+      "integrity": "sha512-rZ4eIFOPrLPM0bAMW560v/i9WeAz6D6PPtmFJ/Rwh7F5QFbg+jSXAyGvg7V9ZwzA5OaXqsToyJBR7qtGXBXAhQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extensions": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-hard-break": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.11.1.tgz",
+      "integrity": "sha512-JMp6CizdB7LoY2jmaZub2D+Aj6RJTkSu0EhIcN/bmBrm4MjYa/ir6nRoo4/gYGIHzHwgwGR/1KmlqTJZW/xl4g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-heading": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.11.1.tgz",
+      "integrity": "sha512-b9ShCSQhWXNzdbdn9a3j33cq646nS0EpVyNBQr0BMOpIcMI4Ot8LGEvPo0BNqPPvpjMJaP2N6xp+EIdk6tunfQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-horizontal-rule": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.11.1.tgz",
+      "integrity": "sha512-9zr6dItcJvzZtFlC+dyFb5VfWGzKzldPAOuln1d/GwKrBZds53O2vBmu4Jxfy22N9LuwiGB+2PYerq0UkLnxnA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-image": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.11.1.tgz",
+      "integrity": "sha512-lI+FyyHavUXHmDKxvSAdqGAvaYtVesAxHckeA60ZjZu9fBkUnVWHD8uR0TStX7EdOIRBWpzYrG7dDT4EkFVjTA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-italic": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.11.1.tgz",
+      "integrity": "sha512-SrjsU+gvhjoPKelc4YSeC2AQ0lhwLiDWMO7fW83CVitCF8iWXpBSeVCI5SxtPeZNKTZ1ZCc3lIQCeEHOC/gP0g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-link": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.11.1.tgz",
+      "integrity": "sha512-ds5auQnWGcHwC2/c1iEvvybdLPcSDlxsii7FPaZg4LaSGdNojRB0qDRZw5dzYQZbfIf5vgYGcIVCVjNPZs1UwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "linkifyjs": "^4.3.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-list": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz",
+      "integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-list-item": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.11.1.tgz",
+      "integrity": "sha512-KFw3TAwN6hQ+oDeE3lRqwzCRKhxU1NWf9q5SAwiUxlp/LcEjuhXcYJYX8SHPOLOlTo0P42v1i0KBeLUPKnO58g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-list-keymap": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.11.1.tgz",
+      "integrity": "sha512-MpeuVi+bOHulbN65bOjaeoNJstNuAAEPdLwNjW25c9y2a8b5iZFY8vdVNENDiqq+dI+F5EaFGaEq0FN0uslfiA==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-ordered-list": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.11.1.tgz",
+      "integrity": "sha512-Aphq0kfk6J/hNQennJ+bntvDzqRPT7RVpnow1s4U4dLBsR6PP7X4zEBg96uAv2OW0RjDHFK9NFqpJPbZtQTmFw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/extension-list": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-paragraph": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.11.1.tgz",
+      "integrity": "sha512-a3lm1WvYewAP2IESq+qnbOtLSJ9yULY2Bj/6DvBq9fzWpb2gSlUdElYh6JLunxB1HEPECTuuRsNPdTrMsSpV4g==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-strike": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.11.1.tgz",
+      "integrity": "sha512-1LfkHNkrGR509cPRgcMr95+nWcAHE0JDm9LkuzdexunhCfJ2tl/h1rA14x3sic8GxQFqEnMefvBUpUbQwPydYw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-text": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.11.1.tgz",
+      "integrity": "sha512-5E94ggkFAZ7OSFSwnofAsmxqmSStRoeCB8AnRuWrR+nnXi43Rq7yptdejQaLi13Z9fSVdnF6h+pB3ua2Exg6WQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-text-align": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.11.1.tgz",
+      "integrity": "sha512-lZvM8HF4qlHuXX1u0ngj1Si1zVzWhS+RiF5kczScul+F1lEQgK+ugL6iF87MSc1yxw5eZQDpA0byx1N+ZqZWZg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-text-style": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.11.1.tgz",
+      "integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extension-underline": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.11.1.tgz",
+      "integrity": "sha512-Y3EJxfE1g4XSGbUZN+74o38mp3O+BQXtlqxAQvedzXiGGrdK2kWhp2b4nj3IkxHdRdoSijf+oZzgyBrRDdgC/w==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/extensions": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz",
+      "integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      }
+    },
+    "node_modules/@tiptap/pm": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz",
+      "integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "prosemirror-changeset": "^2.3.0",
+        "prosemirror-collab": "^1.3.1",
+        "prosemirror-commands": "^1.6.2",
+        "prosemirror-dropcursor": "^1.8.1",
+        "prosemirror-gapcursor": "^1.3.2",
+        "prosemirror-history": "^1.4.1",
+        "prosemirror-inputrules": "^1.4.0",
+        "prosemirror-keymap": "^1.2.2",
+        "prosemirror-markdown": "^1.13.1",
+        "prosemirror-menu": "^1.2.4",
+        "prosemirror-model": "^1.24.1",
+        "prosemirror-schema-basic": "^1.2.3",
+        "prosemirror-schema-list": "^1.5.0",
+        "prosemirror-state": "^1.4.3",
+        "prosemirror-tables": "^1.6.4",
+        "prosemirror-trailing-node": "^3.0.0",
+        "prosemirror-transform": "^1.10.2",
+        "prosemirror-view": "^1.38.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
+    "node_modules/@tiptap/react": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.11.1.tgz",
+      "integrity": "sha512-aPInZbpSWYzJvCFXaY6EhxD+H5ITURElUmUXBoRvlAB6QrR6NIWBt68hNe8i+aDGmuvLS18g60HWK5S6K2RjWQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/use-sync-external-store": "^0.0.6",
+        "fast-deep-equal": "^3.1.3",
+        "use-sync-external-store": "^1.4.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      },
+      "optionalDependencies": {
+        "@tiptap/extension-bubble-menu": "^3.11.1",
+        "@tiptap/extension-floating-menu": "^3.11.1"
+      },
+      "peerDependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/pm": "^3.11.1",
+        "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/@tiptap/starter-kit": {
+      "version": "3.11.1",
+      "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.11.1.tgz",
+      "integrity": "sha512-weRrhp0p5J6cMNcybYobhbOVrgym7KYIwBblJ/1M1snykg+avZawVk2M5Y7j9gM1p2zo112MCw8z8nOa9Yrwow==",
+      "license": "MIT",
+      "dependencies": {
+        "@tiptap/core": "^3.11.1",
+        "@tiptap/extension-blockquote": "^3.11.1",
+        "@tiptap/extension-bold": "^3.11.1",
+        "@tiptap/extension-bullet-list": "^3.11.1",
+        "@tiptap/extension-code": "^3.11.1",
+        "@tiptap/extension-code-block": "^3.11.1",
+        "@tiptap/extension-document": "^3.11.1",
+        "@tiptap/extension-dropcursor": "^3.11.1",
+        "@tiptap/extension-gapcursor": "^3.11.1",
+        "@tiptap/extension-hard-break": "^3.11.1",
+        "@tiptap/extension-heading": "^3.11.1",
+        "@tiptap/extension-horizontal-rule": "^3.11.1",
+        "@tiptap/extension-italic": "^3.11.1",
+        "@tiptap/extension-link": "^3.11.1",
+        "@tiptap/extension-list": "^3.11.1",
+        "@tiptap/extension-list-item": "^3.11.1",
+        "@tiptap/extension-list-keymap": "^3.11.1",
+        "@tiptap/extension-ordered-list": "^3.11.1",
+        "@tiptap/extension-paragraph": "^3.11.1",
+        "@tiptap/extension-strike": "^3.11.1",
+        "@tiptap/extension-text": "^3.11.1",
+        "@tiptap/extension-underline": "^3.11.1",
+        "@tiptap/extensions": "^3.11.1",
+        "@tiptap/pm": "^3.11.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/ueberdosis"
+      }
+    },
     "node_modules/@tweenjs/tween.js": {
       "version": "23.1.3",
       "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -1886,6 +2408,28 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+      "license": "MIT"
+    },
+    "node_modules/@types/markdown-it": {
+      "version": "14.1.2",
+      "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+      "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/linkify-it": "^5",
+        "@types/mdurl": "^2"
+      }
+    },
+    "node_modules/@types/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+      "license": "MIT"
+    },
     "node_modules/@types/node": {
       "version": "24.10.1",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@@ -1901,7 +2445,6 @@
       "version": "19.2.7",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
       "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
-      "devOptional": true,
       "license": "MIT",
       "peer": true,
       "dependencies": {
@@ -1912,8 +2455,8 @@
       "version": "19.2.3",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
-      "dev": true,
       "license": "MIT",
+      "peer": true,
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
@@ -2310,7 +2853,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
       "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
-      "dev": true,
       "license": "Python-2.0"
     },
     "node_modules/autoprefixer": {
@@ -2524,6 +3066,12 @@
       "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
       "license": "MIT"
     },
+    "node_modules/crelt": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+      "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+      "license": "MIT"
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2543,7 +3091,6 @@
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-      "devOptional": true,
       "license": "MIT"
     },
     "node_modules/d3-array": {
@@ -2729,6 +3276,18 @@
         "node": ">=10.13.0"
       }
     },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
     "node_modules/es-toolkit": {
       "version": "1.42.0",
       "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz",
@@ -2795,7 +3354,6 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
       "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=10"
@@ -2999,7 +3557,6 @@
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
       "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/fast-json-stable-stringify": {
@@ -3706,6 +4263,21 @@
       "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==",
       "license": "MIT"
     },
+    "node_modules/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+      "license": "MIT",
+      "dependencies": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "node_modules/linkifyjs": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+      "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+      "license": "MIT"
+    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3758,6 +4330,29 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
+    "node_modules/markdown-it": {
+      "version": "14.1.0",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+      "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "^4.4.0",
+        "linkify-it": "^5.0.0",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+      "license": "MIT"
+    },
     "node_modules/meshoptimizer": {
       "version": "0.22.0",
       "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
@@ -3845,6 +4440,12 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/orderedmap": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+      "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+      "license": "MIT"
+    },
     "node_modules/p-limit": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -3990,6 +4591,204 @@
       "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
       "license": "MIT"
     },
+    "node_modules/prosemirror-changeset": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+      "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-collab": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+      "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-commands": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+      "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.10.2"
+      }
+    },
+    "node_modules/prosemirror-dropcursor": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+      "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0",
+        "prosemirror-view": "^1.1.0"
+      }
+    },
+    "node_modules/prosemirror-gapcursor": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz",
+      "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.0.0",
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-view": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-history": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+      "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.2.2",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.31.0",
+        "rope-sequence": "^1.3.0"
+      }
+    },
+    "node_modules/prosemirror-inputrules": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
+      "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-keymap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+      "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-state": "^1.0.0",
+        "w3c-keyname": "^2.2.0"
+      }
+    },
+    "node_modules/prosemirror-markdown": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
+      "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/markdown-it": "^14.0.0",
+        "markdown-it": "^14.0.0",
+        "prosemirror-model": "^1.25.0"
+      }
+    },
+    "node_modules/prosemirror-menu": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
+      "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
+      "license": "MIT",
+      "dependencies": {
+        "crelt": "^1.0.0",
+        "prosemirror-commands": "^1.0.0",
+        "prosemirror-history": "^1.0.0",
+        "prosemirror-state": "^1.0.0"
+      }
+    },
+    "node_modules/prosemirror-model": {
+      "version": "1.25.4",
+      "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
+      "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "orderedmap": "^2.0.0"
+      }
+    },
+    "node_modules/prosemirror-schema-basic": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
+      "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.25.0"
+      }
+    },
+    "node_modules/prosemirror-schema-list": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+      "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.7.3"
+      }
+    },
+    "node_modules/prosemirror-state": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+      "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "prosemirror-model": "^1.0.0",
+        "prosemirror-transform": "^1.0.0",
+        "prosemirror-view": "^1.27.0"
+      }
+    },
+    "node_modules/prosemirror-tables": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz",
+      "integrity": "sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-keymap": "^1.2.2",
+        "prosemirror-model": "^1.25.0",
+        "prosemirror-state": "^1.4.3",
+        "prosemirror-transform": "^1.10.3",
+        "prosemirror-view": "^1.39.1"
+      }
+    },
+    "node_modules/prosemirror-trailing-node": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+      "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@remirror/core-constants": "3.0.0",
+        "escape-string-regexp": "^4.0.0"
+      },
+      "peerDependencies": {
+        "prosemirror-model": "^1.22.1",
+        "prosemirror-state": "^1.4.2",
+        "prosemirror-view": "^1.33.8"
+      }
+    },
+    "node_modules/prosemirror-transform": {
+      "version": "1.10.5",
+      "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz",
+      "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==",
+      "license": "MIT",
+      "dependencies": {
+        "prosemirror-model": "^1.21.0"
+      }
+    },
+    "node_modules/prosemirror-view": {
+      "version": "1.41.3",
+      "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
+      "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
+      "license": "MIT",
+      "peer": true,
+      "dependencies": {
+        "prosemirror-model": "^1.20.0",
+        "prosemirror-state": "^1.0.0",
+        "prosemirror-transform": "^1.1.0"
+      }
+    },
     "node_modules/punycode": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4000,6 +4799,15 @@
         "node": ">=6"
       }
     },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/react": {
       "version": "19.2.0",
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
@@ -4221,6 +5029,12 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/rope-sequence": {
+      "version": "1.3.4",
+      "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+      "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+      "license": "MIT"
+    },
     "node_modules/safe-buffer": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -4444,6 +5258,12 @@
         "typescript": ">=4.8.4 <6.0.0"
       }
     },
+    "node_modules/uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+      "license": "MIT"
+    },
     "node_modules/undici-types": {
       "version": "7.16.0",
       "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -4605,6 +5425,12 @@
         }
       }
     },
+    "node_modules/w3c-keyname": {
+      "version": "2.2.8",
+      "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+      "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+      "license": "MIT"
+    },
     "node_modules/which": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

+ 8 - 0
frontend/package.json

@@ -14,6 +14,14 @@
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@tanstack/react-query": "^5.90.11",
+    "@tiptap/extension-color": "^3.11.1",
+    "@tiptap/extension-image": "^3.11.1",
+    "@tiptap/extension-link": "^3.11.1",
+    "@tiptap/extension-text-align": "^3.11.1",
+    "@tiptap/extension-text-style": "^3.11.1",
+    "@tiptap/extension-underline": "^3.11.1",
+    "@tiptap/react": "^3.11.1",
+    "@tiptap/starter-kit": "^3.11.1",
     "@types/three": "^0.181.0",
     "gcode-preview": "^2.18.0",
     "jszip": "^3.10.1",

BIN
frontend/public/img/android-chrome-192x192.png


BIN
frontend/public/img/android-chrome-512x512.png


BIN
frontend/public/img/apple-touch-icon.png


BIN
frontend/public/img/favicon-16x16.png


BIN
frontend/public/img/favicon-32x32.png


BIN
frontend/public/img/favicon.png


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

@@ -286,6 +286,45 @@ export const api = {
       has_gcode: boolean;
       build_volume: { x: number; y: number; z: number };
     }>(`/archives/${id}/capabilities`),
+  // Project Page
+  getArchiveProjectPage: (id: number) =>
+    request<{
+      title: string | null;
+      description: string | null;
+      designer: string | null;
+      designer_user_id: string | null;
+      license: string | null;
+      copyright: string | null;
+      creation_date: string | null;
+      modification_date: string | null;
+      origin: string | null;
+      profile_title: string | null;
+      profile_description: string | null;
+      profile_cover: string | null;
+      profile_user_id: string | null;
+      profile_user_name: string | null;
+      design_model_id: string | null;
+      design_profile_id: string | null;
+      design_region: string | null;
+      model_pictures: Array<{ name: string; path: string; url: string }>;
+      profile_pictures: Array<{ name: string; path: string; url: string }>;
+      thumbnails: Array<{ name: string; path: string; url: string }>;
+    }>(`/archives/${id}/project-page`),
+  updateArchiveProjectPage: (id: number, data: {
+    title?: string;
+    description?: string;
+    designer?: string;
+    license?: string;
+    copyright?: string;
+    profile_title?: string;
+    profile_description?: string;
+  }) =>
+    request(`/archives/${id}/project-page`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>
+    `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   reprintArchive: (archiveId: number, printerId: number) =>

+ 475 - 0
frontend/src/components/ProjectPageModal.tsx

@@ -0,0 +1,475 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  X,
+  User,
+  Calendar,
+  FileText,
+  Image,
+  Edit3,
+  Save,
+  ExternalLink,
+  ChevronLeft,
+  ChevronRight,
+} from 'lucide-react';
+import { api } from '../api/client';
+import { Button } from './Button';
+import { RichTextEditor } from './RichTextEditor';
+
+interface ProjectPageModalProps {
+  archiveId: number;
+  archiveName?: string;
+  onClose: () => void;
+}
+
+export function ProjectPageModal({ archiveId, archiveName, onClose }: ProjectPageModalProps) {
+  const queryClient = useQueryClient();
+  const [isEditing, setIsEditing] = useState(false);
+  const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
+  const [editData, setEditData] = useState<{
+    title?: string;
+    description?: string;
+    designer?: string;
+    license?: string;
+    profile_title?: string;
+    profile_description?: string;
+  }>({});
+
+  const { data: projectPage, isLoading, error } = useQuery({
+    queryKey: ['archive-project-page', archiveId],
+    queryFn: () => api.getArchiveProjectPage(archiveId),
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: (data: typeof editData) => api.updateArchiveProjectPage(archiveId, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['archive-project-page', archiveId] });
+      setIsEditing(false);
+      setEditData({});
+    },
+  });
+
+  // Handle escape key to close modal
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (selectedImageIndex !== null) {
+          setSelectedImageIndex(null);
+        } else if (isEditing) {
+          handleCancelEdit();
+        } else {
+          onClose();
+        }
+      }
+    };
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [selectedImageIndex, isEditing, onClose]);
+
+  // Combine all images for gallery
+  const allImages = [
+    ...(projectPage?.model_pictures || []),
+    ...(projectPage?.profile_pictures || []),
+  ];
+
+  const handleStartEdit = () => {
+    setEditData({
+      title: projectPage?.title || '',
+      description: projectPage?.description || '',
+      designer: projectPage?.designer || '',
+      license: projectPage?.license || '',
+      profile_title: projectPage?.profile_title || '',
+      profile_description: projectPage?.profile_description || '',
+    });
+    setIsEditing(true);
+  };
+
+  const handleSave = () => {
+    updateMutation.mutate(editData);
+  };
+
+  const handleCancelEdit = () => {
+    setIsEditing(false);
+    setEditData({});
+  };
+
+  // Sanitize HTML content (basic XSS prevention)
+  const sanitizeHtml = (html: string) => {
+    // Allow basic formatting tags only
+    const allowed = ['p', 'br', 'b', 'strong', 'i', 'em', 'u', 'a', 'ul', 'ol', 'li', 'figure', 'img'];
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+
+    const clean = (node: Node): string => {
+      if (node.nodeType === Node.TEXT_NODE) {
+        return node.textContent || '';
+      }
+      if (node.nodeType === Node.ELEMENT_NODE) {
+        const el = node as Element;
+        const tag = el.tagName.toLowerCase();
+
+        if (!allowed.includes(tag)) {
+          // Return children content without the tag
+          return Array.from(el.childNodes).map(clean).join('');
+        }
+
+        // Build allowed attributes
+        let attrs = '';
+        if (tag === 'a' && el.getAttribute('href')) {
+          const href = el.getAttribute('href');
+          if (href?.toLowerCase().startsWith('http')) {
+            attrs = ` href="${href}" target="_blank" rel="noopener noreferrer"`;
+          }
+        }
+        if (tag === 'img') {
+          const src = el.getAttribute('src');
+          // Only render img if it has a valid http(s) URL, otherwise skip entirely
+          if (!src?.toLowerCase().startsWith('http')) {
+            return ''; // Skip images without valid URLs
+          }
+          attrs = ` src="${src}" style="max-width: 100%; height: auto;"`;
+        }
+
+        const children = Array.from(el.childNodes).map(clean).join('');
+
+        if (['br', 'img'].includes(tag)) {
+          return `<${tag}${attrs} />`;
+        }
+        return `<${tag}${attrs}>${children}</${tag}>`;
+      }
+      return '';
+    };
+
+    return Array.from(doc.body.childNodes).map(clean).join('');
+  };
+
+  const hasContent = projectPage && (
+    projectPage.title ||
+    projectPage.description ||
+    projectPage.designer ||
+    projectPage.profile_title ||
+    allImages.length > 0
+  );
+
+  // Handle backdrop click to close modal
+  const handleBackdropClick = (e: React.MouseEvent) => {
+    if (e.target === e.currentTarget) {
+      onClose();
+    }
+  };
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={handleBackdropClick}
+    >
+      <div className="bg-bambu-dark-secondary rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-3">
+            <FileText className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">
+              Project Page
+              {archiveName && <span className="text-bambu-gray ml-2">- {archiveName}</span>}
+            </h2>
+          </div>
+          <div className="flex items-center gap-2">
+            {!isEditing && hasContent && (
+              <Button variant="ghost" size="sm" onClick={handleStartEdit}>
+                <Edit3 className="w-4 h-4 mr-1" />
+                Edit
+              </Button>
+            )}
+            {isEditing && (
+              <>
+                <Button variant="ghost" size="sm" onClick={handleCancelEdit}>
+                  Cancel
+                </Button>
+                <Button
+                  variant="primary"
+                  size="sm"
+                  onClick={handleSave}
+                  disabled={updateMutation.isPending}
+                >
+                  <Save className="w-4 h-4 mr-1" />
+                  Save
+                </Button>
+              </>
+            )}
+            <button
+              onClick={onClose}
+              className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
+            >
+              <X className="w-5 h-5 text-bambu-gray" />
+            </button>
+          </div>
+        </div>
+
+        {/* Content */}
+        <div className="flex-1 overflow-y-auto p-6">
+          {isLoading && (
+            <div className="flex items-center justify-center py-12">
+              <div className="animate-spin rounded-full h-8 w-8 border-2 border-bambu-green border-t-transparent" />
+            </div>
+          )}
+
+          {error && (
+            <div className="text-red-400 text-center py-12">
+              Failed to load project page data
+            </div>
+          )}
+
+          {projectPage && !hasContent && (
+            <div className="text-bambu-gray text-center py-12">
+              <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
+              <p>No project page data found in this 3MF file.</p>
+              <p className="text-sm mt-2">
+                Project pages are typically included in files downloaded from MakerWorld.
+              </p>
+            </div>
+          )}
+
+          {projectPage && hasContent && (
+            <div className="space-y-6">
+              {/* Title & Designer */}
+              <div className="space-y-4">
+                {isEditing ? (
+                  <input
+                    type="text"
+                    value={editData.title || ''}
+                    onChange={(e) => setEditData({ ...editData, title: e.target.value })}
+                    placeholder="Title"
+                    className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-4 py-2 text-white text-xl font-semibold"
+                  />
+                ) : (
+                  projectPage.title && (
+                    <h3 className="text-xl font-semibold text-white">{projectPage.title}</h3>
+                  )
+                )}
+
+                <div className="flex flex-wrap gap-4 text-sm">
+                  {isEditing ? (
+                    <div className="flex items-center gap-2">
+                      <User className="w-4 h-4 text-bambu-gray" />
+                      <input
+                        type="text"
+                        value={editData.designer || ''}
+                        onChange={(e) => setEditData({ ...editData, designer: e.target.value })}
+                        placeholder="Designer"
+                        className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
+                      />
+                    </div>
+                  ) : (
+                    projectPage.designer && (
+                      <div className="flex items-center gap-2 text-bambu-gray">
+                        <User className="w-4 h-4" />
+                        <span>{projectPage.designer}</span>
+                        {projectPage.designer_user_id && (
+                          <a
+                            href={`https://makerworld.com/en/@${projectPage.designer_user_id}`}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="text-bambu-green hover:underline"
+                          >
+                            <ExternalLink className="w-3 h-3" />
+                          </a>
+                        )}
+                      </div>
+                    )
+                  )}
+
+                  {projectPage.creation_date && (
+                    <div className="flex items-center gap-2 text-bambu-gray">
+                      <Calendar className="w-4 h-4" />
+                      <span>{projectPage.creation_date}</span>
+                    </div>
+                  )}
+
+                  {isEditing ? (
+                    <div className="flex items-center gap-2">
+                      <FileText className="w-4 h-4 text-bambu-gray" />
+                      <input
+                        type="text"
+                        value={editData.license || ''}
+                        onChange={(e) => setEditData({ ...editData, license: e.target.value })}
+                        placeholder="License"
+                        className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-white"
+                      />
+                    </div>
+                  ) : (
+                    projectPage.license && (
+                      <div className="flex items-center gap-2 text-bambu-gray">
+                        <FileText className="w-4 h-4" />
+                        <span>{projectPage.license}</span>
+                      </div>
+                    )
+                  )}
+
+                  {projectPage.origin && (
+                    <span className="px-2 py-0.5 bg-bambu-dark rounded text-bambu-gray">
+                      {projectPage.origin}
+                    </span>
+                  )}
+                </div>
+              </div>
+
+              {/* Description */}
+              {(projectPage.description || isEditing) && (
+                <div className="space-y-2">
+                  <h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
+                    Description
+                  </h4>
+                  {isEditing ? (
+                    <RichTextEditor
+                      content={editData.description || ''}
+                      onChange={(html) => setEditData({ ...editData, description: html })}
+                      placeholder="Enter description..."
+                    />
+                  ) : (
+                    <div
+                      className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
+                      dangerouslySetInnerHTML={{
+                        __html: sanitizeHtml(projectPage.description || ''),
+                      }}
+                    />
+                  )}
+                </div>
+              )}
+
+              {/* Profile Info */}
+              {(projectPage.profile_title || projectPage.profile_description || isEditing) && (
+                <div className="space-y-2 p-4 bg-bambu-dark rounded-lg">
+                  <h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide">
+                    Print Profile
+                  </h4>
+                  {isEditing ? (
+                    <div className="space-y-2">
+                      <input
+                        type="text"
+                        value={editData.profile_title || ''}
+                        onChange={(e) => setEditData({ ...editData, profile_title: e.target.value })}
+                        placeholder="Profile Title"
+                        className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-white"
+                      />
+                      <RichTextEditor
+                        content={editData.profile_description || ''}
+                        onChange={(html) => setEditData({ ...editData, profile_description: html })}
+                        placeholder="Profile description..."
+                      />
+                    </div>
+                  ) : (
+                    <>
+                      {projectPage.profile_title && (
+                        <p className="text-white font-medium">{projectPage.profile_title}</p>
+                      )}
+                      {projectPage.profile_description && (
+                        <div
+                          className="prose prose-invert prose-sm max-w-none text-bambu-gray-light"
+                          dangerouslySetInnerHTML={{
+                            __html: sanitizeHtml(projectPage.profile_description),
+                          }}
+                        />
+                      )}
+                      {projectPage.profile_user_name && (
+                        <p className="text-sm text-bambu-gray">
+                          by {projectPage.profile_user_name}
+                        </p>
+                      )}
+                    </>
+                  )}
+                </div>
+              )}
+
+              {/* Image Gallery */}
+              {allImages.length > 0 && (
+                <div className="space-y-2">
+                  <h4 className="text-sm font-medium text-bambu-gray uppercase tracking-wide flex items-center gap-2">
+                    <Image className="w-4 h-4" />
+                    Images ({allImages.length})
+                  </h4>
+                  <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
+                    {allImages.map((img, index) => (
+                      <button
+                        key={img.path}
+                        onClick={() => setSelectedImageIndex(index)}
+                        className="aspect-square rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
+                      >
+                        <img
+                          src={img.url}
+                          alt={img.name}
+                          className="w-full h-full object-cover"
+                        />
+                      </button>
+                    ))}
+                  </div>
+                </div>
+              )}
+
+              {/* MakerWorld Link */}
+              {projectPage.design_model_id && (
+                <div className="pt-4 border-t border-bambu-dark-tertiary">
+                  <a
+                    href={`https://makerworld.com/en/models/${projectPage.design_model_id}`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="inline-flex items-center gap-2 text-bambu-green hover:underline"
+                  >
+                    <ExternalLink className="w-4 h-4" />
+                    View on MakerWorld
+                  </a>
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+      </div>
+
+      {/* Image Lightbox */}
+      {selectedImageIndex !== null && allImages[selectedImageIndex] && (
+        <div
+          className="fixed inset-0 bg-black/90 flex items-center justify-center z-60"
+          onClick={() => setSelectedImageIndex(null)}
+        >
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              setSelectedImageIndex(Math.max(0, selectedImageIndex - 1));
+            }}
+            disabled={selectedImageIndex === 0}
+            className="absolute left-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
+          >
+            <ChevronLeft className="w-6 h-6 text-white" />
+          </button>
+
+          <img
+            src={allImages[selectedImageIndex].url}
+            alt={allImages[selectedImageIndex].name}
+            className="max-w-[90vw] max-h-[90vh] object-contain"
+            onClick={(e) => e.stopPropagation()}
+          />
+
+          <button
+            onClick={(e) => {
+              e.stopPropagation();
+              setSelectedImageIndex(Math.min(allImages.length - 1, selectedImageIndex + 1));
+            }}
+            disabled={selectedImageIndex === allImages.length - 1}
+            className="absolute right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary disabled:opacity-30"
+          >
+            <ChevronRight className="w-6 h-6 text-white" />
+          </button>
+
+          <button
+            onClick={() => setSelectedImageIndex(null)}
+            className="absolute top-4 right-4 p-2 bg-bambu-dark-secondary rounded-full hover:bg-bambu-dark-tertiary"
+          >
+            <X className="w-6 h-6 text-white" />
+          </button>
+
+          <div className="absolute bottom-4 text-white text-sm">
+            {selectedImageIndex + 1} / {allImages.length}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 191 - 0
frontend/src/components/RichTextEditor.tsx

@@ -0,0 +1,191 @@
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Link from '@tiptap/extension-link';
+import Underline from '@tiptap/extension-underline';
+import TextAlign from '@tiptap/extension-text-align';
+import { TextStyle } from '@tiptap/extension-text-style';
+import Color from '@tiptap/extension-color';
+import Image from '@tiptap/extension-image';
+import {
+  Bold,
+  Italic,
+  Underline as UnderlineIcon,
+  List,
+  ListOrdered,
+  AlignLeft,
+  AlignCenter,
+  AlignRight,
+  Link as LinkIcon,
+  Unlink,
+} from 'lucide-react';
+
+interface RichTextEditorProps {
+  content: string;
+  onChange: (html: string) => void;
+  placeholder?: string;
+}
+
+export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
+  const editor = useEditor({
+    extensions: [
+      StarterKit.configure({
+        heading: false,
+        codeBlock: false,
+        code: false,
+      }),
+      Underline,
+      Link.configure({
+        openOnClick: false,
+        HTMLAttributes: {
+          target: '_blank',
+          rel: 'noopener noreferrer',
+        },
+      }),
+      TextAlign.configure({
+        types: ['paragraph'],
+      }),
+      TextStyle,
+      Color,
+      Image.configure({
+        HTMLAttributes: {
+          style: 'max-width: 100%; height: auto;',
+        },
+      }),
+    ],
+    content,
+    onUpdate: ({ editor }) => {
+      onChange(editor.getHTML());
+    },
+    editorProps: {
+      attributes: {
+        class: 'prose prose-invert prose-sm max-w-none focus:outline-none min-h-[120px] px-3 py-2',
+        placeholder: placeholder || '',
+      },
+    },
+  });
+
+  if (!editor) {
+    return null;
+  }
+
+  const ToolbarButton = ({
+    onClick,
+    isActive = false,
+    children,
+    title,
+  }: {
+    onClick: () => void;
+    isActive?: boolean;
+    children: React.ReactNode;
+    title: string;
+  }) => (
+    <button
+      type="button"
+      onClick={onClick}
+      title={title}
+      className={`p-1.5 rounded hover:bg-bambu-dark-tertiary transition-colors ${
+        isActive ? 'bg-bambu-dark-tertiary text-bambu-green' : 'text-bambu-gray'
+      }`}
+    >
+      {children}
+    </button>
+  );
+
+  const setLink = () => {
+    const url = window.prompt('Enter URL:');
+    if (url) {
+      editor.chain().focus().setLink({ href: url }).run();
+    }
+  };
+
+  return (
+    <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden bg-bambu-dark">
+      {/* Toolbar */}
+      <div className="flex items-center gap-0.5 p-1.5 border-b border-bambu-dark-tertiary bg-bambu-dark-secondary">
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleBold().run()}
+          isActive={editor.isActive('bold')}
+          title="Bold"
+        >
+          <Bold className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleItalic().run()}
+          isActive={editor.isActive('italic')}
+          title="Italic"
+        >
+          <Italic className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleUnderline().run()}
+          isActive={editor.isActive('underline')}
+          title="Underline"
+        >
+          <UnderlineIcon className="w-4 h-4" />
+        </ToolbarButton>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
+
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleBulletList().run()}
+          isActive={editor.isActive('bulletList')}
+          title="Bullet List"
+        >
+          <List className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().toggleOrderedList().run()}
+          isActive={editor.isActive('orderedList')}
+          title="Numbered List"
+        >
+          <ListOrdered className="w-4 h-4" />
+        </ToolbarButton>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
+
+        <ToolbarButton
+          onClick={() => editor.chain().focus().setTextAlign('left').run()}
+          isActive={editor.isActive({ textAlign: 'left' })}
+          title="Align Left"
+        >
+          <AlignLeft className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().setTextAlign('center').run()}
+          isActive={editor.isActive({ textAlign: 'center' })}
+          title="Align Center"
+        >
+          <AlignCenter className="w-4 h-4" />
+        </ToolbarButton>
+        <ToolbarButton
+          onClick={() => editor.chain().focus().setTextAlign('right').run()}
+          isActive={editor.isActive({ textAlign: 'right' })}
+          title="Align Right"
+        >
+          <AlignRight className="w-4 h-4" />
+        </ToolbarButton>
+
+        <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
+
+        <ToolbarButton
+          onClick={setLink}
+          isActive={editor.isActive('link')}
+          title="Add Link"
+        >
+          <LinkIcon className="w-4 h-4" />
+        </ToolbarButton>
+        {editor.isActive('link') && (
+          <ToolbarButton
+            onClick={() => editor.chain().focus().unsetLink().run()}
+            title="Remove Link"
+          >
+            <Unlink className="w-4 h-4" />
+          </ToolbarButton>
+        )}
+      </div>
+
+      {/* Editor */}
+      <EditorContent editor={editor} />
+    </div>
+  );
+}

+ 17 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -33,6 +33,7 @@ import {
   ScanSearch,
   QrCode,
   Camera,
+  FileText,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
@@ -48,6 +49,7 @@ import { BatchTagModal } from '../components/BatchTagModal';
 import { CalendarView } from '../components/CalendarView';
 import { QRCodeModal } from '../components/QRCodeModal';
 import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
+import { ProjectPageModal } from '../components/ProjectPageModal';
 import { useToast } from '../contexts/ToastContext';
 
 function formatFileSize(bytes: number): string {
@@ -95,6 +97,7 @@ function ArchiveCard({
   const [showTimelapse, setShowTimelapse] = useState(false);
   const [showQRCode, setShowQRCode] = useState(false);
   const [showPhotos, setShowPhotos] = useState(false);
+  const [showProjectPage, setShowProjectPage] = useState(false);
   const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
 
   const timelapseScanMutation = useMutation({
@@ -210,6 +213,11 @@ function ArchiveCard({
       onClick: () => setShowPhotos(true),
       disabled: !archive.photos?.length,
     },
+    {
+      label: 'Project Page',
+      icon: <FileText className="w-4 h-4" />,
+      onClick: () => setShowProjectPage(true),
+    },
     { label: '', divider: true, onClick: () => {} },
     {
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
@@ -598,6 +606,15 @@ function ArchiveCard({
           }}
         />
       )}
+
+      {/* Project Page Modal */}
+      {showProjectPage && (
+        <ProjectPageModal
+          archiveId={archive.id}
+          archiveName={archive.print_name || archive.filename}
+          onClose={() => setShowProjectPage(false)}
+        />
+      )}
     </Card>
   );
 }

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


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


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


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


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-BrIMpS3z.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-h3ik9UJ6.css">
+    <script type="module" crossorigin src="/assets/index-CjLW6Pu8.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DyMfP52F.css">
   </head>
   <body>
     <div id="root"></div>

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