Browse Source

feat(projects): URL field + cover photo on project cards (#1155)

  Two new project fields: a free-text URL rendered as a one-click
  external-link button beside the project name on every card (opens in a
  new tab, click is e.stopPropagation()-guarded so it doesn't enter the
  project), and a cover photo that replaces the status-icon box with a
  square thumbnail.

  URL is plumbed through ProjectCreate/Update/Response/ListResponse,
  including from-template + create-template flows so it inherits between
  a project and its template. Cover photo is not inherited because the
  file would be shared on disk between source and copy.

  Schema validator rejects anything other than http:// or https://
  prefixes -- <a href> rendering would otherwise execute javascript:
  / data: / file: URLs even with React's default escaping. PATCH uses
  model_fields_set for the URL field so users can clear it by sending
  {"url": null}.

  Cover image storage: Project.cover_image_filename references a file
  Cover image storage: Project.cover_image_filename references a file
  inside the existing archives/projects/{id}/attachments/ dir, but it's
  tracked separately from the attachments JSON list so swap/delete on
  the cover doesn't perturb the user's other attachments. Three routes
  (POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
  .png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
  place (prior file deleted before the new one lands so repeat uploads
  can't accumulate orphans), and self-heal when a DB reference points at
  a vanished disk file by clearing the column and 404'ing.

  GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
  (accepts ?token=... query string) -- not the bearer-token gate -- so
  <img src> requests work in both auth-on and auth-off configurations.
  The frontend wraps getProjectCoverImageUrl with withStreamToken(),
  matching the existing pattern from getArchiveThumbnail.

  Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
  gate is implicit via the stream-token credential. Migration: 2
  idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
  UI languages.
maziggy 4 weeks ago
parent
commit
57af8a1c19

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -185,6 +185,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Track plates (print jobs) and parts separately
 - Track plates (print jobs) and parts separately
 - Auto-detect parts count from 3MF files
 - Auto-detect parts count from 3MF files
 - Color-coded project badges
 - Color-coded project badges
+- **Project URL + cover photo** — paste a MakerWorld/Printables/Thingiverse link and upload a hero image so each card is immediately recognisable; the URL renders as a one-click link beside the project name
 - Bulk assign archives via multi-select toolbar
 - Bulk assign archives via multi-select toolbar
 - Import/Export projects as ZIP (includes files) or JSON
 - Import/Export projects as ZIP (includes files) or JSON
 - Print or queue files from linked library folders directly in the project view (resulting archive auto-linked to the project)
 - Print or queue files from linked library folders directly in the project view (resulting archive auto-linked to the project)

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

@@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.library import get_library_dir
 from backend.app.api.routes.library import get_library_dir
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
@@ -255,6 +255,8 @@ async def list_projects(
                 queue_count=queue_count,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
                 progress_percent=progress_percent,
                 archives=archive_previews,
                 archives=archive_previews,
+                url=project.url,
+                cover_image_filename=project.cover_image_filename,
             )
             )
         )
         )
 
 
@@ -289,6 +291,7 @@ async def create_project(
         priority=data.priority,
         priority=data.priority,
         budget=data.budget,
         budget=data.budget,
         parent_id=data.parent_id,
         parent_id=data.parent_id,
+        url=data.url,
     )
     )
     db.add(project)
     db.add(project)
     await db.flush()
     await db.flush()
@@ -306,6 +309,8 @@ async def create_project(
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         tags=project.tags,
         due_date=project.due_date,
         due_date=project.due_date,
         priority=project.priority,
         priority=project.priority,
@@ -355,6 +360,8 @@ async def list_templates(
                 queue_count=0,
                 queue_count=0,
                 progress_percent=None,
                 progress_percent=None,
                 archives=[],
                 archives=[],
+                url=project.url,
+                cover_image_filename=project.cover_image_filename,
             )
             )
         )
         )
 
 
@@ -391,6 +398,7 @@ async def create_project_from_template(
         budget=template.budget,
         budget=template.budget,
         is_template=False,
         is_template=False,
         template_source_id=template.id,
         template_source_id=template.id,
+        url=template.url,
     )
     )
     db.add(project)
     db.add(project)
     await db.flush()
     await db.flush()
@@ -428,6 +436,8 @@ async def create_project_from_template(
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         tags=project.tags,
         due_date=project.due_date,
         due_date=project.due_date,
         priority=project.priority,
         priority=project.priority,
@@ -511,6 +521,8 @@ async def get_project(
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         tags=project.tags,
         due_date=project.due_date,
         due_date=project.due_date,
         priority=project.priority,
         priority=project.priority,
@@ -567,6 +579,9 @@ async def update_project(
         project.priority = data.priority
         project.priority = data.priority
     if "budget" in data.model_fields_set:
     if "budget" in data.model_fields_set:
         project.budget = data.budget
         project.budget = data.budget
+    if "url" in data.model_fields_set:
+        # Pydantic validator already guarantees http(s) prefix or None.
+        project.url = data.url
     if data.parent_id is not None:
     if data.parent_id is not None:
         # Verify parent exists and prevent circular reference
         # Verify parent exists and prevent circular reference
         if data.parent_id == project_id:
         if data.parent_id == project_id:
@@ -603,6 +618,8 @@ async def update_project(
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         tags=project.tags,
         due_date=project.due_date,
         due_date=project.due_date,
         priority=project.priority,
         priority=project.priority,
@@ -768,6 +785,19 @@ def get_project_attachments_dir(project_id: int) -> Path:
     return base_dir / "projects" / str(project_id) / "attachments"
     return base_dir / "projects" / str(project_id) / "attachments"
 
 
 
 
+# Cover-image upload accepts only common web-renderable image types (#1155).
+# Subset of ALLOWED_ATTACHMENT_EXTENSIONS minus .svg/.ico because those don't
+# render well as a card thumbnail.
+COVER_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
+COVER_IMAGE_CONTENT_TYPES = {
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".png": "image/png",
+    ".gif": "image/gif",
+    ".webp": "image/webp",
+}
+
+
 # Allowed file extensions for attachments
 # Allowed file extensions for attachments
 ALLOWED_ATTACHMENT_EXTENSIONS = {
 ALLOWED_ATTACHMENT_EXTENSIONS = {
     # Images
     # Images
@@ -985,6 +1015,132 @@ async def delete_attachment(
     }
     }
 
 
 
 
+# ============ #1155: Cover image ============
+
+
+@router.post("/{project_id}/cover-image")
+async def upload_project_cover_image(
+    project_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
+):
+    """Upload (or replace) the project's cover image (#1155).
+
+    Stored alongside other attachments but tracked via Project.cover_image_filename
+    so swap/delete operations don't touch the attachments list. Replaces any
+    existing cover image — the prior file is deleted on disk before the new one
+    lands so a stuck filesystem reference can't accumulate orphaned images.
+    """
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    original_name = file.filename or "cover"
+    ext = os.path.splitext(original_name)[1].lower()
+    if ext not in COVER_IMAGE_EXTENSIONS:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Cover image must be one of {sorted(COVER_IMAGE_EXTENSIONS)}",
+        )
+
+    attachments_dir = get_project_attachments_dir(project_id)
+    attachments_dir.mkdir(parents=True, exist_ok=True)
+
+    # Remove the previous cover-image file from disk first so we don't accumulate
+    # orphans when users repeatedly replace it. Best-effort: a missing/locked file
+    # shouldn't block a successful replacement.
+    if project.cover_image_filename:
+        old_path = attachments_dir / project.cover_image_filename
+        if old_path.exists():
+            try:
+                os.remove(old_path)
+            except OSError as e:
+                logger.warning("Failed to delete old cover image %s: %s", old_path, e)
+
+    unique_filename = f"cover_{uuid.uuid4().hex}{ext}"
+    file_path = attachments_dir / unique_filename
+    try:
+        with open(file_path, "wb") as f:
+            content = await file.read()
+            f.write(content)
+    except OSError as e:
+        logger.error("Failed to save cover image: %s", e)
+        raise HTTPException(status_code=500, detail="Failed to save cover image")
+
+    project.cover_image_filename = unique_filename
+    db.add(project)
+    await db.flush()
+    await db.commit()
+
+    return {
+        "status": "success",
+        "filename": unique_filename,
+        "size": len(content),
+    }
+
+
+@router.get("/{project_id}/cover-image")
+async def get_project_cover_image(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: None = RequireCameraStreamTokenIfAuthEnabled,
+):
+    """Stream the project's cover image (#1155).
+
+    Browsers can't attach `Authorization: Bearer ...` to `<img src>` requests,
+    so this route accepts the same `?token=` stream-credential as
+    /archives/{id}/thumbnail. The frontend wraps URLs with `withStreamToken`."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+    if not project.cover_image_filename:
+        raise HTTPException(status_code=404, detail="No cover image set")
+
+    file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
+    if not file_path.exists():
+        # DB references a file that vanished from disk — clear the dangling
+        # reference so future GETs get a clean 404 instead of repeatedly
+        # touching the filesystem.
+        logger.warning("Cover image file missing for project %s: %s", project_id, file_path)
+        project.cover_image_filename = None
+        await db.commit()
+        raise HTTPException(status_code=404, detail="Cover image file not found")
+
+    ext = os.path.splitext(project.cover_image_filename)[1].lower()
+    media_type = COVER_IMAGE_CONTENT_TYPES.get(ext, "application/octet-stream")
+    return FileResponse(file_path, media_type=media_type)
+
+
+@router.delete("/{project_id}/cover-image")
+async def delete_project_cover_image(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
+):
+    """Remove the project's cover image (#1155)."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    if project.cover_image_filename:
+        file_path = get_project_attachments_dir(project_id) / project.cover_image_filename
+        if file_path.exists():
+            try:
+                os.remove(file_path)
+            except OSError as e:
+                logger.warning("Failed to delete cover image file %s: %s", file_path, e)
+        project.cover_image_filename = None
+        db.add(project)
+        await db.flush()
+        await db.commit()
+
+    return {"status": "success"}
+
+
 # ============ Phase 7: BOM Endpoints ============
 # ============ Phase 7: BOM Endpoints ============
 
 
 
 
@@ -1213,6 +1369,7 @@ async def create_template_from_project(
         budget=source.budget,
         budget=source.budget,
         is_template=True,
         is_template=True,
         template_source_id=source.id,
         template_source_id=source.id,
+        url=source.url,
     )
     )
     db.add(template)
     db.add(template)
     await db.flush()
     await db.flush()
@@ -1250,6 +1407,8 @@ async def create_template_from_project(
         target_parts_count=template.target_parts_count,
         target_parts_count=template.target_parts_count,
         notes=template.notes,
         notes=template.notes,
         attachments=template.attachments,
         attachments=template.attachments,
+        url=template.url,
+        cover_image_filename=template.cover_image_filename,
         tags=template.tags,
         tags=template.tags,
         due_date=template.due_date,
         due_date=template.due_date,
         priority=template.priority,
         priority=template.priority,
@@ -1570,6 +1729,8 @@ async def import_project(
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         tags=project.tags,
         due_date=project.due_date,
         due_date=project.due_date,
         priority=project.priority,
         priority=project.priority,
@@ -1743,6 +1904,8 @@ async def import_project_file(
         target_parts_count=project.target_parts_count,
         target_parts_count=project.target_parts_count,
         notes=project.notes,
         notes=project.notes,
         attachments=project.attachments,
         attachments=project.attachments,
+        url=project.url,
+        cover_image_filename=project.cover_image_filename,
         tags=project.tags,
         tags=project.tags,
         due_date=project.due_date,
         due_date=project.due_date,
         priority=project.priority,
         priority=project.priority,

+ 7 - 0
backend/app/core/database.py

@@ -640,6 +640,13 @@ async def run_migrations(conn):
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
 
 
+    # Migration: Add url + cover_image_filename columns to projects (#1155).
+    # url: external link rendered next to the project name on the card.
+    # cover_image_filename: filename of the project's hero image inside the
+    # existing attachments dir; rendered as a thumbnail on the card.
+    await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
+    await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
+
     # Migration: Make printer_id nullable in print_queue for unassigned queue items
     # Migration: Make printer_id nullable in print_queue for unassigned queue items
     # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
     # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
     # PostgreSQL gets the correct schema from create_all(), so skip this
     # PostgreSQL gets the correct schema from create_all(), so skip this

+ 8 - 0
backend/app/models/project.py

@@ -16,6 +16,14 @@ class Project(Base):
     description: Mapped[str | None] = mapped_column(Text, nullable=True)
     description: Mapped[str | None] = mapped_column(Text, nullable=True)
     color: Mapped[str | None] = mapped_column(String(20), nullable=True)  # Hex color for UI
     color: Mapped[str | None] = mapped_column(String(20), nullable=True)  # Hex color for UI
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
     status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
+
+    # External link rendered as a clickable icon next to the project name (#1155).
+    url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
+    # Filename of the cover photo inside the project's attachments dir; serves as
+    # the card's hero image when set (#1155). The file lives alongside other
+    # attachments but is tracked here separately so users can manage one without
+    # the other.
+    cover_image_filename: Mapped[str | None] = mapped_column(String(255), nullable=True)
     target_count: Mapped[int | None] = mapped_column(
     target_count: Mapped[int | None] = mapped_column(
         Integer, nullable=True
         Integer, nullable=True
     )  # Optional target number of prints (plates)
     )  # Optional target number of prints (plates)

+ 33 - 1
backend/app/schemas/project.py

@@ -1,6 +1,21 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from pydantic import BaseModel
+from pydantic import BaseModel, field_validator
+
+
+def _validate_project_url(value: str | None) -> str | None:
+    """Reject anything that isn't an http(s) URL — the URL is rendered as a
+    clickable `<a href>` so a `javascript:` / `data:` / `file:` value would be
+    an XSS vector even with React's default escaping (#1155)."""
+    if value is None:
+        return value
+    trimmed = value.strip()
+    if not trimmed:
+        return None
+    lowered = trimmed.lower()
+    if not (lowered.startswith("http://") or lowered.startswith("https://")):
+        raise ValueError("url must start with http:// or https://")
+    return trimmed
 
 
 
 
 class ProjectCreate(BaseModel):
 class ProjectCreate(BaseModel):
@@ -17,6 +32,12 @@ class ProjectCreate(BaseModel):
     priority: str = "normal"
     priority: str = "normal"
     budget: float | None = None
     budget: float | None = None
     parent_id: int | None = None  # For sub-projects
     parent_id: int | None = None  # For sub-projects
+    url: str | None = None
+
+    @field_validator("url")
+    @classmethod
+    def _check_url(cls, v: str | None) -> str | None:
+        return _validate_project_url(v)
 
 
 
 
 class ProjectUpdate(BaseModel):
 class ProjectUpdate(BaseModel):
@@ -34,6 +55,12 @@ class ProjectUpdate(BaseModel):
     priority: str | None = None
     priority: str | None = None
     budget: float | None = None
     budget: float | None = None
     parent_id: int | None = None
     parent_id: int | None = None
+    url: str | None = None
+
+    @field_validator("url")
+    @classmethod
+    def _check_url(cls, v: str | None) -> str | None:
+        return _validate_project_url(v)
 
 
 
 
 class ProjectStats(BaseModel):
 class ProjectStats(BaseModel):
@@ -95,6 +122,8 @@ class ProjectResponse(BaseModel):
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
     stats: ProjectStats | None = None
     stats: ProjectStats | None = None
+    url: str | None = None
+    cover_image_filename: str | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
@@ -132,6 +161,9 @@ class ProjectListResponse(BaseModel):
     progress_percent: float | None = None
     progress_percent: float | None = None
     # Preview of archives (up to 5)
     # Preview of archives (up to 5)
     archives: list[ArchivePreview] = []
     archives: list[ArchivePreview] = []
+    # #1155: card-level metadata
+    url: str | None = None
+    cover_image_filename: str | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

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

@@ -113,6 +113,153 @@ class TestProjectsAPI:
         assert response.status_code == 404
         assert response.status_code == 404
 
 
 
 
+class TestProjectUrlAndCoverImage:
+    """Tests for #1155 — url field + cover image upload/get/delete."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        async def _create(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {"name": "URL/Cover Project", "color": "#00ff00"}
+            defaults.update(kwargs)
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_project_accepts_https_url(self, async_client: AsyncClient):
+        response = await async_client.post(
+            "/api/v1/projects/",
+            json={"name": "With URL", "url": "https://makerworld.com/models/12345"},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["url"] == "https://makerworld.com/models/12345"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_project_rejects_javascript_url(self, async_client: AsyncClient):
+        # `<a href>` rendering would execute javascript: URLs — schema must reject.
+        response = await async_client.post(
+            "/api/v1/projects/",
+            json={"name": "Hostile", "url": "javascript:alert(1)"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_project_rejects_data_url(self, async_client: AsyncClient):
+        response = await async_client.post(
+            "/api/v1/projects/",
+            json={"name": "Hostile", "url": "data:text/html,<script>alert(1)</script>"},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_project_clears_url_when_explicitly_null(self, async_client: AsyncClient, project_factory):
+        project = await project_factory(url="https://example.com")
+        response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"url": None})
+        assert response.status_code == 200
+        assert response.json()["url"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_cover_image_then_serve_then_delete(self, async_client: AsyncClient, project_factory):
+        project = await project_factory()
+
+        # 1x1 PNG (smallest valid PNG bytes)
+        png_bytes = bytes.fromhex(
+            "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
+            "890000000d49444154789c63f80f00000100010000000000000049454e44ae42"
+            "6082"
+        )
+        upload = await async_client.post(
+            f"/api/v1/projects/{project.id}/cover-image",
+            files={"file": ("cover.png", png_bytes, "image/png")},
+        )
+        assert upload.status_code == 200, upload.text
+        body = upload.json()
+        assert body["status"] == "success"
+        assert body["filename"].endswith(".png")
+        cover_filename = body["filename"]
+
+        # GET should serve the bytes back
+        served = await async_client.get(f"/api/v1/projects/{project.id}/cover-image")
+        assert served.status_code == 200
+        assert served.headers["content-type"] == "image/png"
+        assert served.content == png_bytes
+
+        # Project response should reflect the cover_image_filename field
+        view = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert view.json()["cover_image_filename"] == cover_filename
+
+        # DELETE should clear the field
+        deleted = await async_client.delete(f"/api/v1/projects/{project.id}/cover-image")
+        assert deleted.status_code == 200
+        view2 = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert view2.json()["cover_image_filename"] is None
+        # And subsequent GET should 404
+        served2 = await async_client.get(f"/api/v1/projects/{project.id}/cover-image")
+        assert served2.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_cover_image_rejects_non_image(self, async_client: AsyncClient, project_factory):
+        project = await project_factory()
+        response = await async_client.post(
+            f"/api/v1/projects/{project.id}/cover-image",
+            files={"file": ("evil.exe", b"MZ\x00\x00", "application/octet-stream")},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.integration
+    def test_cover_image_get_uses_stream_token_gate(self):
+        """Regression guard: GET /projects/{id}/cover-image MUST be gated by
+        ``RequireCameraStreamTokenIfAuthEnabled`` (accepts ``?token=…`` query
+        string) rather than by the bearer-token gate, because browsers can't
+        attach an ``Authorization`` header to ``<img src>`` requests. Swapping
+        back to the bearer gate would silently 401 every cover image when auth
+        is enabled."""
+        from fastapi.routing import APIRoute
+
+        from backend.app.api.routes.projects import router
+
+        # Find the GET cover-image route. The router exposes path/methods/
+        # dependencies via APIRoute objects.
+
+        cover_get = None
+        for route in router.routes:
+            if isinstance(route, APIRoute) and route.path.endswith("/cover-image") and "GET" in route.methods:
+                cover_get = route
+                break
+
+        assert cover_get is not None, "GET cover-image route missing"
+
+        # The route's dependant tree includes a Depends(require_camera_stream_token_if_auth_enabled())
+        # — its `call` is the inner check function returned by that factory.
+        # Walk the dependant tree and assert one of the dependencies came from
+        # the stream-token factory, NOT from require_permission_if_auth_enabled.
+        from backend.app.core.auth import (
+            require_camera_stream_token_if_auth_enabled,
+        )
+
+        # The factory returns a fresh closure each call; the most reliable
+        # signature is the qualified name of the function in the closure chain.
+        expected_qualname = require_camera_stream_token_if_auth_enabled().__qualname__
+
+        gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call]
+        assert expected_qualname in gate_qualnames, (
+            f"GET cover-image route is not gated by RequireCameraStreamTokenIfAuthEnabled. Found: {gate_qualnames}"
+        )
+
+
 class TestProjectPartsTracking:
 class TestProjectPartsTracking:
     """Tests for project parts tracking feature."""
     """Tests for project parts tracking feature."""
 
 

+ 19 - 2
backend/tests/unit/test_printer_manager_status_broadcast.py

@@ -32,6 +32,23 @@ def manager():
     return PrinterManager()
     return PrinterManager()
 
 
 
 
+def _close_unawaited(coro):
+    """Side effect for mocked ``_schedule_async``.
+
+    ``set_awaiting_plate_clear`` evaluates the coroutine expressions
+    ``self._persist_awaiting_plate_clear(...)`` and
+    ``self._broadcast_status_change(...)`` before passing them to
+    ``_schedule_async``. When that target is patched, the coroutine objects
+    leak — Python's ``__del__`` then emits ``coroutine was never awaited``
+    during GC, and when GC runs late enough that warning hits the interpreter
+    shutdown path with ``KeyError: '__import__'``. Closing the coroutine here
+    prevents both. Returns ``None`` so the mock's call signature is unchanged.
+    """
+    if asyncio.iscoroutine(coro):
+        coro.close()
+    return None
+
+
 def _fake_state(**overrides):
 def _fake_state(**overrides):
     """Minimal stand-in for a ``PrinterState`` — only the attributes
     """Minimal stand-in for a ``PrinterState`` — only the attributes
     ``printer_state_to_dict`` reads. We use a SimpleNamespace rather than
     ``printer_state_to_dict`` reads. We use a SimpleNamespace rather than
@@ -58,7 +75,7 @@ class TestSchedulingFromSetAwaitingPlateClear:
         manager._loop = MagicMock()
         manager._loop = MagicMock()
         manager._loop.is_running.return_value = True
         manager._loop.is_running.return_value = True
 
 
-        with patch.object(manager, "_schedule_async") as scheduled:
+        with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
             manager.set_awaiting_plate_clear(7, True)
             manager.set_awaiting_plate_clear(7, True)
 
 
         # Two coroutines: persist + broadcast. Order doesn't matter.
         # Two coroutines: persist + broadcast. Order doesn't matter.
@@ -96,7 +113,7 @@ class TestSchedulingFromSetAwaitingPlateClear:
         manager._loop = MagicMock()
         manager._loop = MagicMock()
         manager._loop.is_running.return_value = True
         manager._loop.is_running.return_value = True
 
 
-        with patch.object(manager, "_schedule_async") as scheduled:
+        with patch.object(manager, "_schedule_async", side_effect=_close_unawaited) as scheduled:
             manager.set_awaiting_plate_clear(7, True)
             manager.set_awaiting_plate_clear(7, True)
             scheduled.reset_mock()
             scheduled.reset_mock()
             manager.set_awaiting_plate_clear(7, False)
             manager.set_awaiting_plate_clear(7, False)

+ 33 - 1
frontend/src/__tests__/api/client.test.ts

@@ -5,7 +5,7 @@
 import { describe, it, expect, afterEach, vi } from 'vitest';
 import { describe, it, expect, afterEach, vi } from 'vitest';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { setupServer } from 'msw/node';
 import { setupServer } from 'msw/node';
-import { setAuthToken, getAuthToken, api } from '../../api/client';
+import { setAuthToken, getAuthToken, api, setStreamToken } from '../../api/client';
 
 
 // Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)
 // Mock sessionStorage (H-5: tokens are stored in sessionStorage, not localStorage)
 const sessionStorageMock = {
 const sessionStorageMock = {
@@ -297,3 +297,35 @@ describe('Printer control endpoints', () => {
     expect(capturedUrl).toContain('mode=heating');
     expect(capturedUrl).toContain('mode=heating');
   });
   });
 });
 });
+
+// #1155 — `<img src>` can't carry an `Authorization: Bearer …` header, so the
+// project cover-image URL must use the same stream-token pattern as
+// /archives/{id}/thumbnail. A regression where `withStreamToken` is removed
+// would break the modal preview AND the card thumbnail when auth is enabled.
+describe('Project cover image URL (#1155)', () => {
+  afterEach(() => {
+    setStreamToken(null);
+  });
+
+  it('appends the stream token query string when one is set', () => {
+    setStreamToken('abc123');
+    const url = api.getProjectCoverImageUrl(42);
+    expect(url).toContain('/projects/42/cover-image');
+    expect(url).toContain('token=abc123');
+  });
+
+  it('returns the bare URL when no stream token is set', () => {
+    setStreamToken(null);
+    const url = api.getProjectCoverImageUrl(42);
+    expect(url).toContain('/projects/42/cover-image');
+    expect(url).not.toContain('token=');
+  });
+
+  it('URL-encodes a token containing query-string-unsafe characters', () => {
+    setStreamToken('a&b=c');
+    const url = api.getProjectCoverImageUrl(7);
+    // Decoded back, the token must round-trip exactly.
+    const params = new URL(url, 'http://x').searchParams;
+    expect(params.get('token')).toBe('a&b=c');
+  });
+});

+ 83 - 0
frontend/src/__tests__/pages/ProjectsPage.test.tsx

@@ -148,4 +148,87 @@ describe('ProjectsPage', () => {
       });
       });
     });
     });
   });
   });
+
+  // #1155 — URL link icon + cover image thumbnail on project cards.
+  describe('URL link and cover image (#1155)', () => {
+    it('renders an external-link icon next to the project name when URL is set', async () => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            {
+              ...mockProjects[0],
+              url: 'https://makerworld.com/models/12345',
+              cover_image_filename: null,
+            },
+          ])
+        )
+      );
+
+      render(<ProjectsPage />);
+
+      const link = await screen.findByLabelText(/Open project URL/i);
+      expect(link).toBeInTheDocument();
+      expect(link.getAttribute('href')).toBe('https://makerworld.com/models/12345');
+      expect(link.getAttribute('target')).toBe('_blank');
+      expect(link.getAttribute('rel')).toContain('noopener');
+    });
+
+    it('does not render the link icon when URL is not set', async () => {
+      // Default fixture has no `url` field — verify the icon is absent.
+      render(<ProjectsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+      });
+      expect(screen.queryByLabelText(/Open project URL/i)).not.toBeInTheDocument();
+    });
+
+    it('clicking the URL link does not bubble to the card onClick', async () => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            {
+              ...mockProjects[0],
+              url: 'https://example.com',
+              cover_image_filename: null,
+            },
+          ])
+        )
+      );
+
+      const user = userEvent.setup();
+      render(<ProjectsPage />);
+
+      const link = await screen.findByLabelText(/Open project URL/i);
+      // Prevent the underlying anchor from triggering jsdom navigation noise
+      // — we only need the propagation guard verified.
+      link.addEventListener('click', (e) => e.preventDefault(), { once: true });
+      await user.click(link);
+
+      // No navigate / detail-page transition should have happened. Card root
+      // is still rendered.
+      expect(screen.getByText('Functional Parts')).toBeInTheDocument();
+    });
+
+    it('renders a cover image thumbnail when cover_image_filename is set', async () => {
+      server.use(
+        http.get('/api/v1/projects/', () =>
+          HttpResponse.json([
+            {
+              ...mockProjects[0],
+              url: null,
+              cover_image_filename: 'cover_abc.png',
+            },
+          ])
+        )
+      );
+
+      render(<ProjectsPage />);
+
+      const img = await screen.findByAltText(/Project cover photo/i);
+      expect(img).toBeInTheDocument();
+      // Card thumbnail uses the GET endpoint URL, project.id is 1.
+      expect(img.getAttribute('src')).toContain('/projects/1/cover-image');
+    });
+  });
 });
 });

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

@@ -647,6 +647,8 @@ export interface Project {
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
   stats?: ProjectStats;
   stats?: ProjectStats;
+  url: string | null;  // External link rendered next to project name on the card (#1155)
+  cover_image_filename: string | null;  // Filename within project attachments dir (#1155)
 }
 }
 
 
 export interface ProjectAttachment {
 export interface ProjectAttachment {
@@ -682,6 +684,8 @@ export interface ProjectListItem {
   queue_count: number;
   queue_count: number;
   progress_percent: number | null;  // Plates progress
   progress_percent: number | null;  // Plates progress
   archives: ArchivePreview[];
   archives: ArchivePreview[];
+  url: string | null;  // #1155
+  cover_image_filename: string | null;  // #1155
 }
 }
 
 
 export interface ProjectCreate {
 export interface ProjectCreate {
@@ -696,6 +700,7 @@ export interface ProjectCreate {
   priority?: string;
   priority?: string;
   budget?: number | null;
   budget?: number | null;
   parent_id?: number;
   parent_id?: number;
+  url?: string | null;  // #1155
 }
 }
 
 
 export interface ProjectUpdate {
 export interface ProjectUpdate {
@@ -711,6 +716,7 @@ export interface ProjectUpdate {
   priority?: string;
   priority?: string;
   budget?: number | null;
   budget?: number | null;
   parent_id?: number;
   parent_id?: number;
+  url?: string | null;  // #1155 — explicit null clears the URL
 }
 }
 
 
 // BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)
 // BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)
@@ -4615,6 +4621,35 @@ export const api = {
       { method: 'DELETE' }
       { method: 'DELETE' }
     ),
     ),
 
 
+  // #1155: Cover image
+  // Browsers can't attach `Authorization: Bearer ...` to `<img src>`, so we
+  // append the stream-token query string the same way archive thumbnails do.
+  getProjectCoverImageUrl: (projectId: number) =>
+    withStreamToken(`${API_BASE}/projects/${projectId}/cover-image`),
+  uploadProjectCoverImage: async (
+    projectId: number,
+    file: File
+  ): Promise<{ status: string; filename: string; size: number }> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/projects/${projectId}/cover-image`, {
+      method: 'POST',
+      headers,
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  deleteProjectCoverImage: (projectId: number) =>
+    request<{ status: string }>(`/projects/${projectId}/cover-image`, { method: 'DELETE' }),
+
   // BOM (Bill of Materials)
   // BOM (Bill of Materials)
   getProjectBOM: (projectId: number) =>
   getProjectBOM: (projectId: number) =>
     request<BOMItem[]>(`/projects/${projectId}/bom`),
     request<BOMItem[]>(`/projects/${projectId}/bom`),

+ 9 - 0
frontend/src/i18n/locales/de.ts

@@ -3034,6 +3034,15 @@ export default {
     // Modal fields
     // Modal fields
     namePlaceholder: 'z.B. Voron 2.4 Build',
     namePlaceholder: 'z.B. Voron 2.4 Build',
     descriptionPlaceholder: 'Optionale Beschreibung...',
     descriptionPlaceholder: 'Optionale Beschreibung...',
+    urlLabel: 'URL',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: 'URL muss mit http:// oder https:// beginnen',
+    openExternalUrl: 'Projekt-URL öffnen',
+    coverImageLabel: 'Titelbild',
+    coverImageAlt: 'Projekt-Titelbild',
+    coverImageUpload: 'Hochladen',
+    coverImageReplace: 'Ersetzen',
+    coverImageRemove: 'Entfernen',
     color: 'Farbe',
     color: 'Farbe',
     targetPlates: 'Ziel-Platten',
     targetPlates: 'Ziel-Platten',
     targetPlatesPlaceholder: 'z.B. 25',
     targetPlatesPlaceholder: 'z.B. 25',

+ 9 - 0
frontend/src/i18n/locales/en.ts

@@ -3037,6 +3037,15 @@ export default {
     // Modal fields
     // Modal fields
     namePlaceholder: 'e.g., Voron 2.4 Build',
     namePlaceholder: 'e.g., Voron 2.4 Build',
     descriptionPlaceholder: 'Optional description...',
     descriptionPlaceholder: 'Optional description...',
+    urlLabel: 'URL',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: 'URL must start with http:// or https://',
+    openExternalUrl: 'Open project URL',
+    coverImageLabel: 'Cover photo',
+    coverImageAlt: 'Project cover photo',
+    coverImageUpload: 'Upload',
+    coverImageReplace: 'Replace',
+    coverImageRemove: 'Remove',
     color: 'Color',
     color: 'Color',
     targetPlates: 'Target Plates',
     targetPlates: 'Target Plates',
     targetPlatesPlaceholder: 'e.g., 25',
     targetPlatesPlaceholder: 'e.g., 25',

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -2956,6 +2956,15 @@ export default {
     // Modal fields
     // Modal fields
     namePlaceholder: 'ex: Build Voron 2.4',
     namePlaceholder: 'ex: Build Voron 2.4',
     descriptionPlaceholder: 'Description optionnelle...',
     descriptionPlaceholder: 'Description optionnelle...',
+    urlLabel: 'URL',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: "L'URL doit commencer par http:// ou https://",
+    openExternalUrl: 'Ouvrir l’URL du projet',
+    coverImageLabel: 'Photo de couverture',
+    coverImageAlt: 'Photo de couverture du projet',
+    coverImageUpload: 'Téléverser',
+    coverImageReplace: 'Remplacer',
+    coverImageRemove: 'Supprimer',
     color: 'Couleur',
     color: 'Couleur',
     targetPlates: 'Plateaux cibles',
     targetPlates: 'Plateaux cibles',
     targetPlatesPlaceholder: 'ex: 25',
     targetPlatesPlaceholder: 'ex: 25',

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -2955,6 +2955,15 @@ export default {
     // Modal fields
     // Modal fields
     namePlaceholder: 'es., Build Voron 2.4',
     namePlaceholder: 'es., Build Voron 2.4',
     descriptionPlaceholder: 'Descrizione opzionale...',
     descriptionPlaceholder: 'Descrizione opzionale...',
+    urlLabel: 'URL',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: "L'URL deve iniziare con http:// o https://",
+    openExternalUrl: 'Apri URL del progetto',
+    coverImageLabel: 'Immagine di copertina',
+    coverImageAlt: 'Immagine di copertina del progetto',
+    coverImageUpload: 'Carica',
+    coverImageReplace: 'Sostituisci',
+    coverImageRemove: 'Rimuovi',
     color: 'Colore',
     color: 'Colore',
     targetPlates: 'Piatti target',
     targetPlates: 'Piatti target',
     targetPlatesPlaceholder: 'es., 25',
     targetPlatesPlaceholder: 'es., 25',

+ 9 - 0
frontend/src/i18n/locales/ja.ts

@@ -2994,6 +2994,15 @@ export default {
     // Modal fields
     // Modal fields
     namePlaceholder: 'プロジェクト名',
     namePlaceholder: 'プロジェクト名',
     descriptionPlaceholder: 'プロジェクトの説明(任意)',
     descriptionPlaceholder: 'プロジェクトの説明(任意)',
+    urlLabel: 'URL',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: 'URLはhttp://またはhttps://で始まる必要があります',
+    openExternalUrl: 'プロジェクトのURLを開く',
+    coverImageLabel: 'カバー画像',
+    coverImageAlt: 'プロジェクトのカバー画像',
+    coverImageUpload: 'アップロード',
+    coverImageReplace: '置き換える',
+    coverImageRemove: '削除',
     color: '色',
     color: '色',
     targetPlates: '目標プレート数',
     targetPlates: '目標プレート数',
     targetPlatesPlaceholder: '例: 10',
     targetPlatesPlaceholder: '例: 10',

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2969,6 +2969,15 @@ export default {
     // Modal fields
     // Modal fields
     namePlaceholder: 'ex., Voron 2.4 Build',
     namePlaceholder: 'ex., Voron 2.4 Build',
     descriptionPlaceholder: 'Descrição opcional...',
     descriptionPlaceholder: 'Descrição opcional...',
+    urlLabel: 'URL',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: 'A URL deve começar com http:// ou https://',
+    openExternalUrl: 'Abrir URL do projeto',
+    coverImageLabel: 'Imagem de capa',
+    coverImageAlt: 'Imagem de capa do projeto',
+    coverImageUpload: 'Enviar',
+    coverImageReplace: 'Substituir',
+    coverImageRemove: 'Remover',
     color: 'Cor',
     color: 'Cor',
     targetPlates: 'Placas Alvo',
     targetPlates: 'Placas Alvo',
     targetPlatesPlaceholder: 'ex., 25',
     targetPlatesPlaceholder: 'ex., 25',

+ 9 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3021,6 +3021,15 @@ export default {
     // Modal fields
     // Modal fields
     namePlaceholder: '例如:Voron 2.4 构建',
     namePlaceholder: '例如:Voron 2.4 构建',
     descriptionPlaceholder: '可选描述...',
     descriptionPlaceholder: '可选描述...',
+    urlLabel: '网址',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: '网址必须以 http:// 或 https:// 开头',
+    openExternalUrl: '打开项目网址',
+    coverImageLabel: '封面图片',
+    coverImageAlt: '项目封面图片',
+    coverImageUpload: '上传',
+    coverImageReplace: '替换',
+    coverImageRemove: '移除',
     color: '颜色',
     color: '颜色',
     targetPlates: '目标板数',
     targetPlates: '目标板数',
     targetPlatesPlaceholder: '例如:25',
     targetPlatesPlaceholder: '例如:25',

+ 9 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3020,6 +3020,15 @@ export default {
     viewDetails: '檢視詳情',
     viewDetails: '檢視詳情',
     // Modal fields
     // Modal fields
     namePlaceholder: '例如:Voron 2.4 構建',
     namePlaceholder: '例如:Voron 2.4 構建',
+    urlLabel: '網址',
+    urlPlaceholder: 'https://makerworld.com/...',
+    urlInvalid: '網址必須以 http:// 或 https:// 開頭',
+    openExternalUrl: '開啟專案網址',
+    coverImageLabel: '封面圖片',
+    coverImageAlt: '專案封面圖片',
+    coverImageUpload: '上傳',
+    coverImageReplace: '替換',
+    coverImageRemove: '移除',
     descriptionPlaceholder: '可選描述...',
     descriptionPlaceholder: '可選描述...',
     color: '顏色',
     color: '顏色',
     targetPlates: '目標板數',
     targetPlates: '目標板數',

+ 154 - 3
frontend/src/pages/ProjectsPage.tsx

@@ -19,6 +19,9 @@ import {
   MoreVertical,
   MoreVertical,
   Download,
   Download,
   Upload,
   Upload,
+  ExternalLink,
+  Image as ImageIcon,
+  X,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
 import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
@@ -62,9 +65,54 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
   const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
   const [dueDate, setDueDate] = useState((project as ProjectListItem & { due_date?: string })?.due_date?.split('T')[0] || '');
   const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
   const [priority, setPriority] = useState((project as ProjectListItem & { priority?: string })?.priority || 'normal');
   const [budget, setBudget] = useState(project?.budget?.toString() || '');
   const [budget, setBudget] = useState(project?.budget?.toString() || '');
+  const [url, setUrl] = useState(project?.url || '');
+  const [urlError, setUrlError] = useState<string | null>(null);
+  const queryClient = useQueryClient();
+  const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
+  const coverFileInputRef = useRef<HTMLInputElement>(null);
+  const [coverUploading, setCoverUploading] = useState(false);
+  // Cache-bust the cover image URL when it changes mid-edit so the preview
+  // refreshes after upload/remove.
+  const [coverCacheKey, setCoverCacheKey] = useState(0);
+
+  const handleCoverFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file || !project) return;
+    setCoverUploading(true);
+    try {
+      const result = await api.uploadProjectCoverImage(project.id, file);
+      setCoverImageFilename(result.filename);
+      setCoverCacheKey((k) => k + 1);
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+    } catch {
+      // Upload failed — leave existing cover image in place.
+    } finally {
+      setCoverUploading(false);
+      if (coverFileInputRef.current) coverFileInputRef.current.value = '';
+    }
+  };
+
+  const handleRemoveCover = async () => {
+    if (!project) return;
+    setCoverUploading(true);
+    try {
+      await api.deleteProjectCoverImage(project.id);
+      setCoverImageFilename(null);
+      setCoverCacheKey((k) => k + 1);
+      queryClient.invalidateQueries({ queryKey: ['projects'] });
+    } finally {
+      setCoverUploading(false);
+    }
+  };
 
 
   const handleSubmit = (e: React.FormEvent) => {
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
+    const trimmedUrl = url.trim();
+    if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
+      setUrlError(t('projects.urlInvalid'));
+      return;
+    }
+    setUrlError(null);
     onSave({
     onSave({
       name: name.trim(),
       name: name.trim(),
       description: description.trim() || undefined,
       description: description.trim() || undefined,
@@ -75,6 +123,10 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
       due_date: dueDate || undefined,
       due_date: dueDate || undefined,
       priority,
       priority,
       budget: budget.trim() ? parseFloat(budget) : null,
       budget: budget.trim() ? parseFloat(budget) : null,
+      // Pydantic accepts null to clear the URL; an empty string would fail the
+      // http(s) prefix validator. Use undefined for create (omit) and null for
+      // edit-with-cleared-value.
+      url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
       ...(project && { status }),
       ...(project && { status }),
     });
     });
   };
   };
@@ -116,6 +168,80 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
             />
             />
           </div>
           </div>
 
 
+          {/* #1155: External URL */}
+          <div>
+            <label className="block text-sm font-medium text-white mb-1">
+              {t('projects.urlLabel')}
+            </label>
+            <input
+              type="url"
+              value={url}
+              onChange={(e) => { setUrl(e.target.value); if (urlError) setUrlError(null); }}
+              className={`w-full bg-bambu-dark border rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none ${
+                urlError ? 'border-red-500 focus:border-red-500' : 'border-bambu-dark-tertiary focus:border-bambu-green'
+              }`}
+              placeholder={t('projects.urlPlaceholder')}
+              maxLength={2048}
+            />
+            {urlError && <p className="text-xs text-red-400 mt-1">{urlError}</p>}
+          </div>
+
+          {/* #1155: Cover image — only available when editing an existing project,
+              since uploading needs a project_id. New projects can add it after save. */}
+          {project && (
+            <div>
+              <label className="block text-sm font-medium text-white mb-1">
+                {t('projects.coverImageLabel')}
+              </label>
+              <div className="flex items-center gap-3">
+                <div className="w-20 h-20 rounded bg-bambu-dark border border-bambu-dark-tertiary overflow-hidden flex items-center justify-center flex-shrink-0">
+                  {coverImageFilename ? (
+                    <img
+                      src={`${api.getProjectCoverImageUrl(project.id)}?v=${coverCacheKey}`}
+                      alt={t('projects.coverImageAlt')}
+                      className="w-full h-full object-cover"
+                    />
+                  ) : (
+                    <ImageIcon className="w-6 h-6 text-bambu-gray" />
+                  )}
+                </div>
+                <div className="flex flex-col gap-2">
+                  <input
+                    ref={coverFileInputRef}
+                    type="file"
+                    accept="image/jpeg,image/png,image/gif,image/webp"
+                    onChange={handleCoverFileChange}
+                    className="hidden"
+                  />
+                  <Button
+                    type="button"
+                    variant="secondary"
+                    onClick={() => coverFileInputRef.current?.click()}
+                    disabled={coverUploading}
+                  >
+                    {coverUploading ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <Upload className="w-4 h-4 mr-1" />
+                    )}
+                    {coverImageFilename ? t('projects.coverImageReplace') : t('projects.coverImageUpload')}
+                  </Button>
+                  {coverImageFilename && (
+                    <Button
+                      type="button"
+                      variant="secondary"
+                      onClick={handleRemoveCover}
+                      disabled={coverUploading}
+                    >
+                      <X className="w-4 h-4 mr-1" />
+                      {t('projects.coverImageRemove')}
+                    </Button>
+                  )}
+                </div>
+              </div>
+            </div>
+          )}
+
           <div>
           <div>
             <label className="block text-sm font-medium text-white mb-1">
             <label className="block text-sm font-medium text-white mb-1">
               {t('projects.color')}
               {t('projects.color')}
@@ -317,12 +443,37 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: P
         {/* Header */}
         {/* Header */}
         <div className="flex items-start justify-between mb-4">
         <div className="flex items-start justify-between mb-4">
           <div className="flex items-center gap-3 min-w-0 flex-1">
           <div className="flex items-center gap-3 min-w-0 flex-1">
-            <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
-              <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
-            </div>
+            {project.cover_image_filename ? (
+              // #1155: cover photo replaces the status-icon box
+              <div className="w-10 h-10 rounded-lg overflow-hidden flex-shrink-0 bg-bambu-dark border border-bambu-dark-tertiary">
+                <img
+                  src={api.getProjectCoverImageUrl(project.id)}
+                  alt={t('projects.coverImageAlt')}
+                  className="w-full h-full object-cover"
+                  loading="lazy"
+                />
+              </div>
+            ) : (
+              <div className={`p-2 rounded-lg ${statusConfig.bg} flex-shrink-0`}>
+                <statusConfig.icon className={`w-5 h-5 ${statusConfig.color}`} />
+              </div>
+            )}
             <div className="min-w-0 flex-1">
             <div className="min-w-0 flex-1">
               <div className="flex items-center gap-2 flex-wrap">
               <div className="flex items-center gap-2 flex-wrap">
                 <h3 className="font-semibold text-white truncate">{project.name}</h3>
                 <h3 className="font-semibold text-white truncate">{project.name}</h3>
+                {project.url && (
+                  <a
+                    href={project.url}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    onClick={(e) => e.stopPropagation()}
+                    title={project.url}
+                    aria-label={t('projects.openExternalUrl')}
+                    className="inline-flex items-center justify-center w-6 h-6 rounded bg-bambu-dark border border-bambu-dark-tertiary text-bambu-green hover:bg-bambu-green/10 hover:border-bambu-green transition-colors flex-shrink-0"
+                  >
+                    <ExternalLink className="w-3.5 h-3.5" />
+                  </a>
+                )}
                 {project.target_parts_count ? (
                 {project.target_parts_count ? (
                   <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
                   <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
                     partsProgressPercent >= 100
                     partsProgressPercent >= 100

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


+ 1 - 1
static/index.html

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

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