Added project page viewer and editor

This commit is contained in:
Martin Ziegler
2025-11-28 12:41:28 +01:00
parent 77605a4fe7
commit 53c94deade
21 changed files with 10499 additions and 8459 deletions
+10
View File
@@ -33,6 +33,7 @@
- **Filament Cost Tracking** - Track costs per print with customizable filament database - **Filament Cost Tracking** - Track costs per print with customizable filament database
- **Photo Attachments** - Attach photos to archived prints for documentation - **Photo Attachments** - Attach photos to archived prints for documentation
- **Failure Analysis** - Document failed prints with notes and photos - **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 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
- **File Manager** - Browse and manage files on your printer's SD card - **File Manager** - Browse and manage files on your printer's SD card
- **Re-print** - Send archived prints back to any connected printer - **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 - Re-print any archived 3MF to a connected printer
- Export archives for backup - 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 ## Tech Stack
- **Backend**: Python / FastAPI - **Backend**: Python / FastAPI
+86
View File
@@ -888,3 +888,89 @@ async def reprint_archive(
"archive_id": archive_id, "archive_id": archive_id,
"filename": archive.filename, "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
View File
@@ -65,3 +65,52 @@ class ArchiveStats(BaseModel):
total_cost: float total_cost: float
prints_by_filament_type: dict prints_by_filament_type: dict
prints_by_printer: 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
View File
@@ -264,6 +264,206 @@ class ThreeMFParser:
break 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: class ArchiveService:
"""Service for archiving print jobs.""" """Service for archiving print jobs."""
+832 -6
View File
@@ -12,6 +12,14 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.90.11", "@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", "@types/three": "^0.181.0",
"gcode-preview": "^2.18.0", "gcode-preview": "^2.18.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -996,6 +1004,23 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1134,6 +1159,12 @@
"url": "https://opencollective.com/immer" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47", "version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@@ -1758,6 +1789,497 @@
"react": "^18 || ^19" "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": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -1886,6 +2408,28 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "24.10.1", "version": "24.10.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
@@ -1901,7 +2445,6 @@
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -1912,8 +2455,8 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -2310,7 +2853,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
@@ -2524,6 +3066,12 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2543,7 +3091,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": { "node_modules/d3-array": {
@@ -2729,6 +3276,18 @@
"node": ">=10.13.0" "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": { "node_modules/es-toolkit": {
"version": "1.42.0", "version": "1.42.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz",
@@ -2795,7 +3354,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@@ -2999,7 +3557,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
@@ -3706,6 +4263,21 @@
"integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==", "integrity": "sha512-nU8j4ND702ouGfQZoaTN4dfXxacvGOAVK0DtmZBVcUYUAeYQXLQAjAN50igMHiba3T5jZyKEjXZU+Ntm1Qs6ZQ==",
"license": "MIT" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3758,6 +4330,29 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/meshoptimizer": {
"version": "0.22.0", "version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
@@ -3845,6 +4440,12 @@
"node": ">= 0.8.0" "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": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "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==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4000,6 +4799,15 @@
"node": ">=6" "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": { "node_modules/react": {
"version": "19.2.0", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
@@ -4221,6 +5029,12 @@
"fsevents": "~2.3.2" "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": { "node_modules/safe-buffer": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -4444,6 +5258,12 @@
"typescript": ">=4.8.4 <6.0.0" "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": { "node_modules/undici-types": {
"version": "7.16.0", "version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+8
View File
@@ -14,6 +14,14 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.90.11", "@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", "@types/three": "^0.181.0",
"gcode-preview": "^2.18.0", "gcode-preview": "^2.18.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+39
View File
@@ -286,6 +286,45 @@ export const api = {
has_gcode: boolean; has_gcode: boolean;
build_volume: { x: number; y: number; z: number }; build_volume: { x: number; y: number; z: number };
}>(`/archives/${id}/capabilities`), }>(`/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) => getArchiveForSlicer: (id: number, filename: string) =>
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`, `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
reprintArchive: (archiveId: number, printerId: number) => reprintArchive: (archiveId: number, printerId: number) =>
@@ -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
View File
@@ -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
View File
@@ -33,6 +33,7 @@ import {
ScanSearch, ScanSearch,
QrCode, QrCode,
Camera, Camera,
FileText,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '../api/client'; import { api } from '../api/client';
import type { Archive } from '../api/client'; import type { Archive } from '../api/client';
@@ -48,6 +49,7 @@ import { BatchTagModal } from '../components/BatchTagModal';
import { CalendarView } from '../components/CalendarView'; import { CalendarView } from '../components/CalendarView';
import { QRCodeModal } from '../components/QRCodeModal'; import { QRCodeModal } from '../components/QRCodeModal';
import { PhotoGalleryModal } from '../components/PhotoGalleryModal'; import { PhotoGalleryModal } from '../components/PhotoGalleryModal';
import { ProjectPageModal } from '../components/ProjectPageModal';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
function formatFileSize(bytes: number): string { function formatFileSize(bytes: number): string {
@@ -95,6 +97,7 @@ function ArchiveCard({
const [showTimelapse, setShowTimelapse] = useState(false); const [showTimelapse, setShowTimelapse] = useState(false);
const [showQRCode, setShowQRCode] = useState(false); const [showQRCode, setShowQRCode] = useState(false);
const [showPhotos, setShowPhotos] = useState(false); const [showPhotos, setShowPhotos] = useState(false);
const [showProjectPage, setShowProjectPage] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const timelapseScanMutation = useMutation({ const timelapseScanMutation = useMutation({
@@ -210,6 +213,11 @@ function ArchiveCard({
onClick: () => setShowPhotos(true), onClick: () => setShowPhotos(true),
disabled: !archive.photos?.length, disabled: !archive.photos?.length,
}, },
{
label: 'Project Page',
icon: <FileText className="w-4 h-4" />,
onClick: () => setShowProjectPage(true),
},
{ label: '', divider: true, onClick: () => {} }, { label: '', divider: true, onClick: () => {} },
{ {
label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', 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> </Card>
); );
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" /> <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="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-BrIMpS3z.js"></script> <script type="module" crossorigin src="/assets/index-CjLW6Pu8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-h3ik9UJ6.css"> <link rel="stylesheet" crossorigin href="/assets/index-DyMfP52F.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>