Browse Source

File Manager: external link, notes and photos on library files (#3128)

Thomansky 2 days ago
parent
commit
12dddada0a
49 changed files with 4817 additions and 57 deletions
  1. 264 0
      backend/app/api/routes/library.py
  2. 11 0
      backend/app/api/routes/users.py
  3. 6 0
      backend/app/core/database.py
  4. 30 1
      backend/app/main.py
  5. 6 0
      backend/app/models/library.py
  6. 36 1
      backend/app/schemas/library.py
  7. 3 1
      backend/app/services/library_trash.py
  8. 71 0
      backend/app/services/print_scheduler.py
  9. 88 0
      backend/app/utils/library_paths.py
  10. 436 0
      backend/tests/integration/test_library_file_metadata_3077.py
  11. 171 0
      backend/tests/integration/test_library_preview_thumbnail_api.py
  12. 108 0
      backend/tests/integration/test_ownership_permissions.py
  13. 55 0
      backend/tests/integration/test_security_headers.py
  14. 67 0
      backend/tests/unit/test_library_photo_move.py
  15. 1 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  16. 228 3
      backend/tests/unit/test_scheduler_cleanup_library.py
  17. 317 1
      frontend/package-lock.json
  18. 6 1
      frontend/package.json
  19. 1 0
      frontend/scripts/check-i18n-parity.mjs
  20. 324 0
      frontend/src/__tests__/components/LibraryFileDetailsModal.test.tsx
  21. 101 0
      frontend/src/__tests__/components/PdfPreviewModal.test.tsx
  22. 114 0
      frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx
  23. 183 0
      frontend/src/__tests__/pages/FileManagerFileDetails.test.tsx
  24. 57 0
      frontend/src/api/client.ts
  25. 346 0
      frontend/src/components/LibraryFileDetailsModal.tsx
  26. 174 12
      frontend/src/components/ModelViewer.tsx
  27. 7 2
      frontend/src/components/ModelViewerModal.tsx
  28. 244 0
      frontend/src/components/PdfPreviewModal.tsx
  29. 55 28
      frontend/src/components/PhotoGalleryModal.tsx
  30. 305 0
      frontend/src/components/SpreadsheetPreviewModal.tsx
  31. 43 0
      frontend/src/i18n/locales/de.ts
  32. 43 0
      frontend/src/i18n/locales/en.ts
  33. 43 0
      frontend/src/i18n/locales/es.ts
  34. 43 0
      frontend/src/i18n/locales/fr.ts
  35. 43 0
      frontend/src/i18n/locales/it.ts
  36. 43 0
      frontend/src/i18n/locales/ja.ts
  37. 43 0
      frontend/src/i18n/locales/ko.ts
  38. 43 0
      frontend/src/i18n/locales/nl.ts
  39. 43 0
      frontend/src/i18n/locales/pt-BR.ts
  40. 43 0
      frontend/src/i18n/locales/ru.ts
  41. 43 0
      frontend/src/i18n/locales/sv.ts
  42. 43 0
      frontend/src/i18n/locales/tr.ts
  43. 43 0
      frontend/src/i18n/locales/uk.ts
  44. 43 0
      frontend/src/i18n/locales/zh-CN.ts
  45. 43 0
      frontend/src/i18n/locales/zh-TW.ts
  46. 241 7
      frontend/src/pages/FileManagerPage.tsx
  47. 31 0
      frontend/src/types/occt-import-js.d.ts
  48. 77 0
      frontend/src/workers/stepPreview.worker.ts
  49. 8 0
      frontend/vite.config.ts

+ 264 - 0
backend/app/api/routes/library.py

@@ -49,6 +49,7 @@ from backend.app.schemas.library import (
     BatchThumbnailResult,
     BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteRequest,
     BulkDeleteResponse,
     BulkDeleteResponse,
+    ClientThumbnailResponse,
     ExternalFolderCreate,
     ExternalFolderCreate,
     FileDuplicate,
     FileDuplicate,
     FileListResponse,
     FileListResponse,
@@ -90,6 +91,7 @@ from backend.app.utils.filename import (
     safe_path_component,
     safe_path_component,
     validate_print_filename,
     validate_print_filename,
 )
 )
+from backend.app.utils.library_paths import library_photos_dir, remove_library_photos_dir
 from backend.app.utils.printer_models import is_gcode_compatible
 from backend.app.utils.printer_models import is_gcode_compatible
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import (
 from backend.app.utils.threemf_tools import (
@@ -808,6 +810,29 @@ def create_image_thumbnail(file_path: Path, thumbnails_dir: Path, max_size: int
 # Supported image extensions for thumbnails
 # Supported image extensions for thumbnails
 IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
 IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif"}
 
 
+# File types whose thumbnails are rendered client-side and uploaded back
+# (#2976). The server has no renderer for these formats — STEP would need
+# OpenCascade, PDF a rasteriser — so the browser posts its first preview
+# render to POST /files/{id}/preview-thumbnail instead. Kept to exactly
+# these types so the endpoint can never overwrite a server-generated
+# STL/3MF/G-code/image thumbnail.
+CLIENT_THUMBNAIL_TYPES = {"step", "stp", "pdf", "csv", "xlsx", "ods"}
+
+# Photos of the printed result (#3077): same allowlist and naming as the
+# archive photo routes. 10 MB is ample for a phone camera JPEG.
+PHOTO_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp")
+PHOTO_MEDIA_TYPES = {
+    ".jpg": "image/jpeg",
+    ".jpeg": "image/jpeg",
+    ".png": "image/png",
+    ".webp": "image/webp",
+}
+MAX_PHOTO_BYTES = 10 * 1024 * 1024
+
+# Upper bound for an uploaded client-rendered thumbnail. The FE sends a
+# 256px PNG (a few tens of KB); anything near this limit is not a thumbnail.
+MAX_CLIENT_THUMBNAIL_BYTES = 2 * 1024 * 1024
+
 
 
 async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
 async def _backfill_external_stl_thumbnails(folder_ids: list[int]) -> None:
     """Generate STL thumbnails for an external folder tree in the background.
     """Generate STL thumbnails for an external folder tree in the background.
@@ -1494,6 +1519,8 @@ async def delete_folder(
 
 
     await delete_dependent_variants(db, doomed_file_ids)
     await delete_dependent_variants(db, doomed_file_ids)
     await release_queue_references(db, doomed_file_ids)
     await release_queue_references(db, doomed_file_ids)
+    for doomed_id in doomed_file_ids:
+        remove_library_photos_dir(doomed_id)
 
 
     # Delete folder (cascade will handle files and subfolders)
     # Delete folder (cascade will handle files and subfolders)
     await db.delete(folder)
     await db.delete(folder)
@@ -1588,6 +1615,12 @@ _SCANNABLE_EXTENSIONS = {
     ".webp",
     ".webp",
     ".svg",
     ".svg",
     ".md",
     ".md",
+    # Documents that ship alongside a job folder and now have in-app
+    # previews (#2976): drawings/datasheets and part lists.
+    ".pdf",
+    ".csv",
+    ".xlsx",
+    ".ods",
 }
 }
 
 
 
 
@@ -2009,6 +2042,10 @@ async def scan_external_folder(
                         abs_thumb.unlink()
                         abs_thumb.unlink()
                 except OSError:
                 except OSError:
                     pass
                     pass
+            # The row is gone for good — external files skip the trash — so
+            # its photos go with it rather than being orphaned under an id
+            # nothing points at any more (#3077).
+            remove_library_photos_dir(db_file.id)
             await db.delete(db_file)
             await db.delete(db_file)
             removed += 1
             removed += 1
 
 
@@ -2229,6 +2266,9 @@ async def list_files(
                 tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
                 tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
                 variant_group_id=f.variant_group_id,
                 variant_group_id=f.variant_group_id,
                 variant_count=variant_counts.get(f.variant_group_id, 0) if f.variant_group_id else 0,
                 variant_count=variant_counts.get(f.variant_group_id, 0) if f.variant_group_id else 0,
+                external_url=f.external_url,
+                has_notes=bool(f.notes),
+                photo_count=len(f.photos or []),
             )
             )
         )
         )
 
 
@@ -5058,6 +5098,9 @@ async def get_file(
         print_count=file.print_count,
         print_count=file.print_count,
         last_printed_at=file.last_printed_at,
         last_printed_at=file.last_printed_at,
         notes=file.notes,
         notes=file.notes,
+        external_url=file.external_url,
+        photos=list(file.photos or []),
+        source_url=file.source_url,
         duplicates=duplicates if duplicates else None,
         duplicates=duplicates if duplicates else None,
         duplicate_count=duplicate_count,
         duplicate_count=duplicate_count,
         created_by_id=file.created_by_id,
         created_by_id=file.created_by_id,
@@ -5132,6 +5175,9 @@ async def update_file(
     if data.notes is not None:
     if data.notes is not None:
         file.notes = data.notes if data.notes else None
         file.notes = data.notes if data.notes else None
 
 
+    if data.external_url is not None:
+        file.external_url = data.external_url.strip() or None
+
     await db.commit()
     await db.commit()
     await db.refresh(file)
     await db.refresh(file)
 
 
@@ -5186,6 +5232,7 @@ async def delete_file(
 
 
         await delete_dependent_variants(db, [file.id])
         await delete_dependent_variants(db, [file.id])
         await release_queue_references(db, [file.id])
         await release_queue_references(db, [file.id])
+        remove_library_photos_dir(file.id)
         await db.delete(file)
         await db.delete(file)
         await db.commit()
         await db.commit()
         return {"status": "success", "message": "File deleted", "trashed": False}
         return {"status": "success", "message": "File deleted", "trashed": False}
@@ -5328,6 +5375,218 @@ async def get_thumbnail(
     return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type)
     return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type)
 
 
 
 
+@router.post("/files/{file_id}/preview-thumbnail", response_model=ClientThumbnailResponse)
+async def upload_preview_thumbnail(
+    file_id: int,
+    thumbnail: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+):
+    """Store a client-rendered preview thumbnail for a file (#2976).
+
+    STEP, PDF and spreadsheet previews are rendered in the browser; the FE
+    posts its first render here so the grid gets a thumbnail without the
+    server needing OpenCascade or a PDF rasteriser. Only file types in
+    ``CLIENT_THUMBNAIL_TYPES`` are accepted, and only while the file has no
+    thumbnail yet — a stored thumbnail is never replaced by this route.
+    """
+    user, can_modify_all = auth_result
+
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    # Ownership check (same shape as update_file)
+    if not can_modify_all:
+        if file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only update your own files")
+
+    if file.file_type not in CLIENT_THUMBNAIL_TYPES:
+        raise HTTPException(status_code=400, detail="File type does not accept client-rendered thumbnails")
+
+    if file.thumbnail_path:
+        return ClientThumbnailResponse(updated=False)
+
+    content = await thumbnail.read(MAX_CLIENT_THUMBNAIL_BYTES + 1)
+    if len(content) > MAX_CLIENT_THUMBNAIL_BYTES:
+        raise HTTPException(status_code=413, detail="Thumbnail too large")
+
+    # Decode and re-encode through PIL: validates the bytes are a real PNG
+    # and strips anything that isn't pixel data before it lands on disk.
+    import io
+
+    from PIL import Image, UnidentifiedImageError
+
+    try:
+        with Image.open(io.BytesIO(content)) as img:
+            img.load()
+            if img.format != "PNG":
+                raise HTTPException(status_code=400, detail="Thumbnail must be a PNG image")
+            if img.mode not in ("RGB", "RGBA"):
+                img = img.convert("RGBA")
+            # The grid renders at ~256px; cap outliers instead of storing them.
+            if img.width > 512 or img.height > 512:
+                img.thumbnail((512, 512), Image.Resampling.LANCZOS)
+            thumbnails_dir = get_library_thumbnails_dir()
+            thumb_filename = f"{uuid.uuid4().hex}.png"
+            thumb_path = thumbnails_dir / thumb_filename  # SEC-PATH-OK: thumb_filename = uuid.uuid4().hex + ".png"
+            img.save(thumb_path, "PNG", optimize=True)
+    except HTTPException:
+        raise
+    except (UnidentifiedImageError, OSError, ValueError) as e:
+        raise HTTPException(status_code=400, detail="Invalid thumbnail image") from e
+
+    file.thumbnail_path = to_relative_path(thumb_path)
+    await db.commit()
+
+    return ClientThumbnailResponse(updated=True)
+
+
+# ============ Photo Endpoints (#3077) ============
+
+
+@router.post("/files/{file_id}/photos")
+async def upload_file_photo(
+    file_id: int,
+    file: UploadFile = File(...),
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+):
+    """Attach a photo of the printed result to a library file.
+
+    Photos are Bambuddy-side metadata, so external files take them too. Same
+    shape as the archive photo upload: extension allowlist, uuid-named on
+    disk, and the ``photos`` list re-assigned so SQLAlchemy sees the change.
+    """
+    user, can_modify_all = auth_result
+
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+    library_file = result.scalar_one_or_none()
+
+    if not library_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    # Ownership check (same shape as update_file)
+    if not can_modify_all:
+        if library_file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only update your own files")
+
+    if not file.filename or not file.filename.lower().endswith(PHOTO_EXTENSIONS):
+        raise HTTPException(status_code=400, detail="File must be an image (.jpg, .jpeg, .png, .webp)")
+
+    content = await file.read(MAX_PHOTO_BYTES + 1)
+    if len(content) > MAX_PHOTO_BYTES:
+        raise HTTPException(status_code=413, detail="Photo too large (max 10 MB)")
+
+    photos_dir = library_photos_dir(library_file.id)
+    photos_dir.mkdir(parents=True, exist_ok=True)
+
+    ext = Path(file.filename).suffix.lower()
+    photo_filename = f"{uuid.uuid4().hex[:8]}{ext}"
+    photo_path = photos_dir / photo_filename  # SEC-PATH-OK: photo_filename = uuid.uuid4().hex[:8] + ext
+    photo_path.write_bytes(content)
+
+    photos = list(library_file.photos or [])
+    photos.append(photo_filename)
+    library_file.photos = photos
+
+    await db.commit()
+    await db.refresh(library_file)
+
+    return {"status": "uploaded", "filename": photo_filename, "photos": library_file.photos}
+
+
+@router.get("/files/{file_id}/photos/{filename}")
+async def get_file_photo(
+    file_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_media_token_ownership(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+):
+    """Serve one photo. Media-token auth like the thumbnail route (#3025)."""
+    user, can_read_all = auth_result
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+    library_file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
+
+    # Membership check first: names are uuid-generated on upload, so anything
+    # not in the stored list is not a photo, whatever is on disk.
+    if not library_file.photos or filename not in library_file.photos:
+        raise HTTPException(status_code=404, detail="Photo not found")
+
+    try:
+        photo_path = safe_join_under(library_photos_dir(library_file.id), filename, http=False)
+    except PathTraversalError:
+        raise HTTPException(status_code=404, detail="Photo not found") from None
+    if not photo_path.is_file():
+        raise HTTPException(status_code=404, detail="Photo not found")
+
+    media_type = PHOTO_MEDIA_TYPES.get(Path(filename).suffix.lower(), "image/jpeg")
+    return FastAPIFileResponse(str(photo_path), media_type=media_type)
+
+
+@router.delete("/files/{file_id}/photos/{filename}")
+async def delete_file_photo(
+    file_id: int,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+):
+    """Remove a photo from a library file."""
+    user, can_modify_all = auth_result
+
+    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
+    library_file = result.scalar_one_or_none()
+
+    if not library_file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    if not can_modify_all:
+        if library_file.created_by_id != user.id:
+            raise HTTPException(status_code=403, detail="You can only update your own files")
+
+    if not library_file.photos or filename not in library_file.photos:
+        raise HTTPException(status_code=404, detail="Photo not found")
+
+    try:
+        photo_path = safe_join_under(library_photos_dir(library_file.id), filename, http=False)
+    except PathTraversalError:
+        raise HTTPException(status_code=404, detail="Photo not found") from None
+    if photo_path.is_file():
+        try:
+            photo_path.unlink()
+        except OSError as e:
+            logger.warning("Failed to delete photo from disk: %s", e)
+
+    photos = [p for p in library_file.photos if p != filename]
+    library_file.photos = photos if photos else None
+
+    await db.commit()
+
+    return {"status": "deleted", "photos": library_file.photos or []}
+
+
 @router.get("/files/{file_id}/gcode")
 @router.get("/files/{file_id}/gcode")
 async def get_gcode(
 async def get_gcode(
     file_id: int,
     file_id: int,
@@ -5558,6 +5817,7 @@ async def bulk_delete(
         await delete_dependent_variants(db, hard_deleted_ids)
         await delete_dependent_variants(db, hard_deleted_ids)
         await release_queue_references(db, hard_deleted_ids)
         await release_queue_references(db, hard_deleted_ids)
         for file in hard_deleted:
         for file in hard_deleted:
+            remove_library_photos_dir(file.id)
             await db.delete(file)
             await db.delete(file)
 
 
     # Delete folders (cascade will handle contents). Folders have no ownership
     # Delete folders (cascade will handle contents). Folders have no ownership
@@ -5580,6 +5840,10 @@ async def bulk_delete(
             tree_file_ids = await _folder_tree_file_ids(db, folder_id)
             tree_file_ids = await _folder_tree_file_ids(db, folder_id)
             await delete_dependent_variants(db, tree_file_ids)
             await delete_dependent_variants(db, tree_file_ids)
             await release_queue_references(db, tree_file_ids)
             await release_queue_references(db, tree_file_ids)
+            # The cascade hard-deletes every row in the subtree, so their
+            # photos go with them — same as DELETE /folders/{id} (#3077).
+            for doomed_id in tree_file_ids:
+                remove_library_photos_dir(doomed_id)
             await db.delete(folder)
             await db.delete(folder)
             deleted_folders += 1
             deleted_folders += 1
 
 

+ 11 - 0
backend/app/api/routes/users.py

@@ -50,6 +50,7 @@ from backend.app.services.email_service import (
     send_email,
     send_email,
 )
 )
 from backend.app.services.finance_defaults import ensure_user_finance_defaults
 from backend.app.services.finance_defaults import ensure_user_finance_defaults
+from backend.app.utils.library_paths import remove_library_photos_dir
 
 
 router = APIRouter(prefix="/users", tags=["users"])
 router = APIRouter(prefix="/users", tags=["users"])
 
 
@@ -444,7 +445,14 @@ async def delete_user(
             detail="Cannot delete your own account",
             detail="Cannot delete your own account",
         )
         )
 
 
+    # Photo directories of the library rows about to go, resolved while the
+    # rows still exist to say which ids they belong to. Removed after the
+    # commit, so a failed delete leaves the pictures alone (#3077).
+    doomed_library_file_ids: list[int] = []
     if delete_items:
     if delete_items:
+        doomed_library_file_ids = list(
+            (await db.execute(select(LibraryFile.id).where(LibraryFile.created_by_id == user_id))).scalars().all()
+        )
         # Delete all items created by this user
         # Delete all items created by this user
         await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
         await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
         await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
         await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
@@ -494,6 +502,9 @@ async def delete_user(
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     await db.commit()
 
 
+    for file_id in doomed_library_file_ids:
+        remove_library_photos_dir(file_id)
+
 
 
 @router.post("/me/change-password", response_model=dict)
 @router.post("/me/change-password", response_model=dict)
 async def change_own_password(
 async def change_own_password(

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

@@ -4955,6 +4955,12 @@ async def run_migrations(conn):
         conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
         conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
     )
     )
 
 
+    # Migration: user link + photos on library files (#3077), the same trio
+    # print_archives carries (external_url / photos). Photos are stored under
+    # <archive_dir>/library/photos/<file_id>/ — the column only holds the names.
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN external_url VARCHAR(500)")
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN photos JSON")
+
     # Migration: storage location sensor alerts (#2824), own column rather than
     # Migration: storage location sensor alerts (#2824), own column rather than
     # reusing on_ha_sensor_alert. That column can be scoped to one printer
     # reusing on_ha_sensor_alert. That column can be scoped to one printer
     # (printer_id), and a location alert has no printer to scope by — sharing
     # (printer_id), and a location alert has no printer to scope by — sharing

+ 30 - 1
backend/app/main.py

@@ -4,6 +4,7 @@ import logging
 import math
 import math
 import os
 import os
 import posixpath
 import posixpath
+import re
 import secrets
 import secrets
 import time
 import time
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
@@ -9609,6 +9610,12 @@ def _frame_ancestors(default_value: str) -> str:
     return f"frame-ancestors {default_value};"
     return f"frame-ancestors {default_value};"
 
 
 
 
+# The Vite-emitted STEP preview worker chunk (#2976): src/workers/
+# stepPreview.worker.ts becomes /assets/stepPreview.worker-<hash>.js. Matched
+# exactly so the eval-relaxed CSP below can never apply to any other asset.
+_STEP_WORKER_ASSET_RE = re.compile(r"^/assets/stepPreview\.worker-[\w-]+\.js$")
+
+
 @app.middleware("http")
 @app.middleware("http")
 async def security_headers_middleware(request, call_next):
 async def security_headers_middleware(request, call_next):
     """Add standard HTTP security headers to every response."""
     """Add standard HTTP security headers to every response."""
@@ -9655,6 +9662,23 @@ async def security_headers_middleware(request, call_next):
             "object-src 'none'; "
             "object-src 'none'; "
             "base-uri 'self'; " + _frame_ancestors("'none'")
             "base-uri 'self'; " + _frame_ancestors("'none'")
         )
         )
+    elif _STEP_WORKER_ASSET_RE.match(request.url.path):
+        # The STEP preview worker (#2976) runs OpenCascade compiled to WASM;
+        # its emscripten/embind glue generates invoker functions with `new
+        # Function(...)`, which needs 'unsafe-eval'. Per CSP3 a dedicated
+        # worker is governed by the policy delivered with the WORKER SCRIPT's
+        # own response — not the document's — so relaxing it here confines
+        # eval to that DOM-less worker context. The document policy below
+        # stays nonce-strict, and this response header has no effect when the
+        # file is merely fetched (a fetch's CSP is enforced against the
+        # requesting document, not the resource's own headers).
+        response.headers["Content-Security-Policy"] = (
+            "default-src 'self'; "
+            "script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'; "
+            "connect-src 'self'; "
+            "object-src 'none'; "
+            "base-uri 'self'; " + _frame_ancestors("'none'")
+        )
     else:
     else:
         # The streaming overlay is embedded same-origin by the URL builder's
         # The streaming overlay is embedded same-origin by the URL builder's
         # preview in Settings (#1422), so this branch allows 'self'.
         # preview in Settings (#1422), so this branch allows 'self'.
@@ -9668,9 +9692,14 @@ async def security_headers_middleware(request, call_next):
         # TRUSTED_FRAME_ORIGINS is for, and _frame_ancestors already folds that
         # TRUSTED_FRAME_ORIGINS is for, and _frame_ancestors already folds that
         # allowlist in.
         # allowlist in.
         embeddable_same_origin = request.url.path.startswith("/overlay/")
         embeddable_same_origin = request.url.path.startswith("/overlay/")
+        # 'wasm-unsafe-eval' permits WebAssembly compilation ONLY — it does
+        # not allow eval()/Function() for JS, unlike 'unsafe-eval'. Needed by
+        # the STEP preview, which triangulates in the browser via OpenCascade
+        # compiled to WASM (#2976). Browsers that predate the keyword ignore
+        # it and simply keep blocking wasm, so this never widens JS execution.
         response.headers["Content-Security-Policy"] = (
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "default-src 'self'; "
-            f"script-src 'self' 'nonce-{csp_nonce}'; "
+            f"script-src 'self' 'wasm-unsafe-eval' 'nonce-{csp_nonce}'; "
             "style-src 'self' 'unsafe-inline'; "
             "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "media-src 'self' blob:; "

+ 6 - 0
backend/app/models/library.py

@@ -147,6 +147,12 @@ class LibraryFile(Base):
     # User notes
     # User notes
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
     notes: Mapped[str | None] = mapped_column(Text, nullable=True)
 
 
+    # User-provided link (Printables, Thingiverse, ...) and photos of the printed
+    # result (#3077) — the same trio archives carry. ``photos`` is a list of
+    # stored filenames under ``library_paths.library_photos_dir(id)``.
+    external_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    photos: Mapped[list | None] = mapped_column(JSON, nullable=True)
+
     # Provenance — when the file was imported from an external source (e.g.
     # Provenance — when the file was imported from an external source (e.g.
     # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
     # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
     # the canonical public URL. Used for "already imported" detection and
     # the canonical public URL. Used for "already imported" detection and

+ 36 - 1
backend/app/schemas/library.py

@@ -2,7 +2,7 @@
 
 
 from datetime import datetime
 from datetime import datetime
 
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
 
 
 # ============ Folder Schemas ============
 # ============ Folder Schemas ============
 
 
@@ -122,6 +122,20 @@ class FileUpdate(BaseModel):
     folder_id: int | None = None
     folder_id: int | None = None
     project_id: int | None = None
     project_id: int | None = None
     notes: str | None = None
     notes: str | None = None
+    # Empty string clears the link, like ``notes`` (#3077).
+    external_url: str | None = Field(None, max_length=500)
+
+    @field_validator("external_url")
+    @classmethod
+    def validate_external_url(cls, v: str | None) -> str | None:
+        # The link is rendered as an href for every reader of the library, so
+        # only web URLs are accepted (no javascript:/data: schemes).
+        if v is None:
+            return None
+        v = v.strip()
+        if v and not v.lower().startswith(("http://", "https://")):
+            raise ValueError("external_url must start with http:// or https://")
+        return v
 
 
 
 
 class FileDuplicate(BaseModel):
 class FileDuplicate(BaseModel):
@@ -157,6 +171,11 @@ class FileResponse(BaseModel):
     last_printed_at: datetime | None
     last_printed_at: datetime | None
 
 
     notes: str | None
     notes: str | None
+    # User link + photos of the printed result (#3077); ``source_url`` is the
+    # read-only import provenance (MakerWorld) shown next to it.
+    external_url: str | None = None
+    photos: list[str] = []
+    source_url: str | None = None
 
 
     # Duplicate detection
     # Duplicate detection
     duplicates: list[FileDuplicate] | None = None
     duplicates: list[FileDuplicate] | None = None
@@ -227,6 +246,12 @@ class FileListResponse(BaseModel):
     variant_group_id: int | None = None
     variant_group_id: int | None = None
     variant_count: int = 0
     variant_count: int = 0
 
 
+    # Metadata indicators (#3077). The list never ships the notes text itself —
+    # ``has_notes`` is enough for the card badge; the details modal loads the rest.
+    external_url: str | None = None
+    has_notes: bool = False
+    photo_count: int = 0
+
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
@@ -413,6 +438,16 @@ class BatchThumbnailResponse(BaseModel):
     results: list[BatchThumbnailResult]
     results: list[BatchThumbnailResult]
 
 
 
 
+class ClientThumbnailResponse(BaseModel):
+    """Schema for the client-rendered preview thumbnail upload response (#2976).
+
+    ``updated`` is false when the file already had a thumbnail — the upload is
+    skipped so a stored thumbnail is never silently replaced.
+    """
+
+    updated: bool
+
+
 # ============ Variant Group Schemas (#671 / #2570) ============
 # ============ Variant Group Schemas (#671 / #2570) ============
 
 
 
 

+ 3 - 1
backend/app/services/library_trash.py

@@ -29,6 +29,7 @@ from backend.app.core.database import async_session
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
+from backend.app.utils.library_paths import remove_library_photos_dir
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.local_time import utcnow_naive
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -364,7 +365,7 @@ class LibraryTrashService:
 
 
     @staticmethod
     @staticmethod
     def _unlink_on_disk(row: LibraryFile) -> None:
     def _unlink_on_disk(row: LibraryFile) -> None:
-        """Best-effort cleanup of the file + thumbnail on disk."""
+        """Best-effort cleanup of the file, thumbnail and photos (#3077) on disk."""
         for rel in (row.file_path, row.thumbnail_path):
         for rel in (row.file_path, row.thumbnail_path):
             abs_path = _to_absolute_path(rel)
             abs_path = _to_absolute_path(rel)
             if abs_path is None:
             if abs_path is None:
@@ -374,6 +375,7 @@ class LibraryTrashService:
                     abs_path.unlink()
                     abs_path.unlink()
             except OSError as e:
             except OSError as e:
                 logger.warning("Trash sweep: failed to unlink %s: %s", abs_path, e)
                 logger.warning("Trash sweep: failed to unlink %s: %s", abs_path, e)
+        remove_library_photos_dir(row.id)
 
 
     # ---- User-facing trash ops ----------------------------------------
     # ---- User-facing trash ops ----------------------------------------
 
 

+ 71 - 0
backend/app/services/print_scheduler.py

@@ -60,9 +60,11 @@ from backend.app.services.printer_manager import (
 )
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.ams_humidity import ams_humidity_percent
+from backend.app.utils.archive_paths import archive_photos_dir
 from backend.app.utils.color_utils import perceptual_color_distance
 from backend.app.utils.color_utils import perceptual_color_distance
 from backend.app.utils.filament_types import canonical_filament_type
 from backend.app.utils.filament_types import canonical_filament_type
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.filename import derive_remote_filename
+from backend.app.utils.library_paths import move_library_photos
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.printer_models import (
 from backend.app.utils.printer_models import (
     is_dual_nozzle_model,
     is_dual_nozzle_model,
@@ -6351,6 +6353,10 @@ class PrintScheduler:
         file_path = None
         file_path = None
         filename = None
         filename = None
         cleanup_disk_paths: list[Path] = []
         cleanup_disk_paths: list[Path] = []
+        # Set when a dispatch consumes its library file, so the photos can be
+        # carried over after the commit that removes the row (#3077).
+        consumed_library_file_id: int | None = None
+        consumed_photos: list[str] = []
 
 
         if item.archive_id:
         if item.archive_id:
             # Print from archive
             # Print from archive
@@ -6452,6 +6458,9 @@ class PrintScheduler:
                             archive_id=archive.id,
                             archive_id=archive.id,
                             dispatched_item_id=item.id,
                             dispatched_item_id=item.id,
                         )
                         )
+                        # Read while the row is still here; the photos move
+                        # below, once the delete has actually committed.
+                        consumed_photos = list(library_file.photos or [])
                         await db.delete(library_file)
                         await db.delete(library_file)
                         file_path = settings.base_dir / archive.file_path
                         file_path = settings.base_dir / archive.file_path
                         filename = archive.filename
                         filename = archive.filename
@@ -6493,6 +6502,68 @@ class PrintScheduler:
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
                 return
                 return
 
 
+            # The photos follow the file into the archive that replaces it, for
+            # the same reason the siblings do (#3077). After the commit above,
+            # never before it: that commit can fail ("database is locked",
+            # #1853) and roll the library row back, and photos already moved
+            # would leave it naming a directory that no longer exists. The
+            # file and thumbnail unlinks are deferred for the same reason.
+            if consumed_library_file_id is not None and consumed_photos:
+                # Held as a plain int, read here while the session is still
+                # healthy, because the handler below may not touch an ORM
+                # instance at all. The commit it exists for fails inside the
+                # FLUSH, not at COMMIT: SQLite takes the write lock at the
+                # first DML statement, so a busy writer surfaces as "database
+                # is locked" on the UPDATE (#1853). SQLAlchemy rolls that back
+                # internally through safe_reraise before re-raising, which
+                # expires every loaded instance and leaves the session in
+                # pending-rollback state -- so `archive.id` inside the except
+                # would itself raise PendingRollbackError and the rollback
+                # below would never be reached.
+                archive_id = archive.id
+                try:
+                    carried_photos = move_library_photos(
+                        consumed_library_file_id,
+                        consumed_photos,
+                        archive_photos_dir(archive),
+                    )
+                    if carried_photos:
+                        archive.photos = list(archive.photos or []) + carried_photos
+                        await db.commit()
+                except Exception as e:
+                    # The archive and the delete are already committed; the
+                    # print goes ahead either way. Worst case the pictures sit
+                    # unnamed in the archive's own directory.
+                    #
+                    # Ints only until the rollback has run, per the note above,
+                    # which is why this logs queue_item_id and not item.id --
+                    # the sibling handler forty lines up does the same.
+                    logger.warning(
+                        "Queue item %s: failed to carry library photos into archive %s: %s",
+                        queue_item_id,
+                        archive_id,
+                        e,
+                    )
+                    await db.rollback()
+                    # rollback() expires every loaded instance, and in async
+                    # SQLAlchemy the next plain attribute read is lazy IO
+                    # outside the greenlet -- MissingGreenlet, which would turn
+                    # this cosmetic failure into a dispatch crash in exactly the
+                    # "database is locked" case the block exists for (#1853).
+                    # The nozzle guard, the upload and the start all keep
+                    # reading item, archive and printer, so all three go back
+                    # into the session before falling through.
+                    item = await db.get(PrintQueueItem, queue_item_id)
+                    archive = await db.get(PrintArchive, archive_id)
+                    printer = await db.get(Printer, item.printer_id) if item else None
+                    if not item or not archive or not printer:
+                        logger.error(
+                            "Queue item %s: item, archive %s or printer gone after the photo rollback",
+                            queue_item_id,
+                            archive_id,
+                        )
+                        return
+
         else:
         else:
             # Neither archive nor library file specified
             # Neither archive nor library file specified
             item.status = "failed"
             item.status = "failed"

+ 88 - 0
backend/app/utils/library_paths.py

@@ -0,0 +1,88 @@
+"""Where a library file's user photos live on disk (#3077).
+
+Photos are Bambuddy-side metadata, so they sit inside the library data dir
+regardless of whether the file itself is managed or external:
+``<archive_dir>/library/photos/<file_id>/``. The routes, the trash sweeper,
+the external-folder scan and the dispatch cleanup all derive the directory
+from here — see ``archive_paths`` for why one path derived in several places
+is a bug waiting to happen.
+"""
+
+from __future__ import annotations
+
+import logging
+import shutil
+import uuid
+from collections.abc import Sequence
+from pathlib import Path
+
+from backend.app.core.config import settings
+from backend.app.utils.safe_path import PathTraversalError, safe_join_under
+
+logger = logging.getLogger(__name__)
+
+
+def library_photos_dir(file_id: int) -> Path:
+    """The photo directory for library file *file_id* (not created)."""
+    library_dir = Path(settings.archive_dir) / "library"
+    return library_dir / "photos" / str(file_id)  # SEC-PATH-OK: file_id is an int primary key
+
+
+def remove_library_photos_dir(file_id: int) -> None:
+    """Best-effort removal of a file's photo directory and everything in it."""
+    photos_dir = library_photos_dir(file_id)
+    if not photos_dir.is_dir():
+        return
+    try:
+        shutil.rmtree(photos_dir)
+    except OSError as e:
+        logger.warning("Failed to remove library photos dir %s: %s", photos_dir, e)
+
+
+def move_library_photos(file_id: int, photos: Sequence[str], destination: Path) -> list[str]:
+    """Move a library file's photos into *destination*, emptying its directory.
+
+    Used where a library row is consumed by the archive that replaces it
+    (``cleanup_library_after_dispatch``): the photos follow the file instead
+    of being orphaned under an id nothing points at any more. Returns the
+    names the photos ended up under, in order — a name already taken in
+    *destination* gets a fresh one, because both sides draw photo names from
+    the same 8-hex-digit alphabet.
+
+    Best-effort: a photo that cannot be moved is left out of the returned
+    list, so it is never named by an archive that does not have it. The
+    caller is mid-dispatch and has nowhere to report to. The source
+    directory is only removed once everything in the list did move, so a
+    failure orphans the pictures rather than destroying them.
+    """
+    source_dir = library_photos_dir(file_id)
+    if not source_dir.is_dir():
+        return []
+    moved: list[str] = []
+    failed = False
+    for filename in photos:
+        try:
+            source = safe_join_under(source_dir, filename, http=False)
+        except PathTraversalError:
+            failed = True
+            continue
+        if not source.is_file():
+            continue
+        target_name = filename
+        try:
+            destination.mkdir(parents=True, exist_ok=True)
+            target = safe_join_under(destination, target_name, http=False)
+            if target.exists():
+                target_name = f"{uuid.uuid4().hex[:8]}{source.suffix.lower()}"
+                target = destination / target_name  # SEC-PATH-OK: uuid.uuid4().hex[:8] + suffix
+            shutil.move(str(source), str(target))
+        except (OSError, PathTraversalError) as e:
+            logger.warning("Failed to move library photo %s to %s: %s", source, destination, e)
+            failed = True
+            continue
+        moved.append(target_name)
+    if failed:
+        logger.warning("Kept library photos dir %s: not every photo reached %s", source_dir, destination)
+    else:
+        remove_library_photos_dir(file_id)
+    return moved

+ 436 - 0
backend/tests/integration/test_library_file_metadata_3077.py

@@ -0,0 +1,436 @@
+"""Integration tests for library file notes, external link and photos (#3077).
+
+Pins the contracts of the details modal's backend: the PUT round-trip for
+``external_url`` (empty string clears), the list-view indicators
+(``has_notes`` / ``photo_count``), and the photo routes — membership check
+before any disk access, extension allowlist, size cap, and the photo
+directory going away with the file.
+"""
+
+import io
+
+import pytest
+from httpx import AsyncClient
+from PIL import Image
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.library import LibraryFile
+from backend.app.models.user import User
+from backend.app.utils.library_paths import library_photos_dir
+
+
+def _jpeg_bytes() -> bytes:
+    buf = io.BytesIO()
+    Image.new("RGB", (32, 32), "red").save(buf, "JPEG")
+    return buf.getvalue()
+
+
+@pytest.fixture
+def isolated_storage(monkeypatch, tmp_path):
+    """Point library storage at a throwaway directory."""
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+    return tmp_path
+
+
+@pytest.fixture
+async def file_factory(db_session):
+    """Factory for LibraryFile rows with sensible defaults."""
+    _counter = [0]
+
+    async def _create_file(**kwargs):
+        _counter[0] += 1
+        counter = _counter[0]
+        defaults = {
+            "filename": f"part{counter}.3mf",
+            "file_path": f"library/files/part{counter}.3mf",
+            "file_type": "3mf",
+            "file_size": 100,
+        }
+        defaults.update(kwargs)
+        library_file = LibraryFile(**defaults)
+        db_session.add(library_file)
+        await db_session.commit()
+        await db_session.refresh(library_file)
+        return library_file
+
+    return _create_file
+
+
+async def _upload(async_client: AsyncClient, file_id: int, name: str = "result.jpg", content: bytes | None = None):
+    return await async_client.post(
+        f"/api/v1/library/files/{file_id}/photos",
+        files={"file": (name, content if content is not None else _jpeg_bytes(), "image/jpeg")},
+    )
+
+
+class TestExternalUrlAndNotes:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_url_round_trip(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory()
+
+        response = await async_client.put(
+            f"/api/v1/library/files/{library_file.id}",
+            json={"external_url": "https://www.printables.com/model/1234", "notes": "Print at 0.2mm"},
+        )
+        assert response.status_code == 200
+        body = response.json()
+        assert body["external_url"] == "https://www.printables.com/model/1234"
+        assert body["notes"] == "Print at 0.2mm"
+        assert body["photos"] == []
+
+        detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
+        assert detail.json()["external_url"] == "https://www.printables.com/model/1234"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_string_clears_external_url(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory(external_url="https://example.com/x")
+
+        response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": ""})
+        assert response.status_code == 200
+        assert response.json()["external_url"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "url",
+        ["javascript:alert(1)", "data:text/html,hi", "ftp://example.com/x", "www.printables.com/model/1"],
+    )
+    async def test_non_http_external_url_is_rejected(
+        self, async_client: AsyncClient, file_factory, isolated_storage, url: str
+    ):
+        library_file = await file_factory(external_url="https://example.com/x")
+
+        response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"external_url": url})
+        assert response.status_code == 422
+
+        detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
+        assert detail.json()["external_url"] == "https://example.com/x"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_omitted_external_url_is_left_alone(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory(external_url="https://example.com/x")
+
+        response = await async_client.put(f"/api/v1/library/files/{library_file.id}", json={"notes": "hi"})
+        assert response.status_code == 200
+        assert response.json()["external_url"] == "https://example.com/x"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_detail_exposes_source_url_read_only(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory(source_type="makerworld", source_url="https://makerworld.com/models/1")
+
+        detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
+        assert detail.json()["source_url"] == "https://makerworld.com/models/1"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_carries_indicators_but_not_notes(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        with_meta = await file_factory(
+            notes="secret notes", external_url="https://example.com/a", photos=["a.jpg", "b.png"]
+        )
+        bare = await file_factory()
+
+        response = await async_client.get("/api/v1/library/files")
+        assert response.status_code == 200
+        by_id = {item["id"]: item for item in response.json()}
+
+        assert by_id[with_meta.id]["has_notes"] is True
+        assert by_id[with_meta.id]["photo_count"] == 2
+        assert by_id[with_meta.id]["external_url"] == "https://example.com/a"
+        assert "notes" not in by_id[with_meta.id]
+        assert "photos" not in by_id[with_meta.id]
+
+        assert by_id[bare.id]["has_notes"] is False
+        assert by_id[bare.id]["photo_count"] == 0
+        assert by_id[bare.id]["external_url"] is None
+
+
+class TestPhotos:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_serve_delete_round_trip(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        library_file = await file_factory()
+
+        upload = await _upload(async_client, library_file.id)
+        assert upload.status_code == 200
+        body = upload.json()
+        filename = body["filename"]
+        assert body["status"] == "uploaded"
+        assert body["photos"] == [filename]
+        assert filename.endswith(".jpg")
+        assert (library_photos_dir(library_file.id) / filename).is_file()
+
+        await db_session.refresh(library_file)
+        assert library_file.photos == [filename]
+
+        served = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
+        assert served.status_code == 200
+        assert served.headers["content-type"] == "image/jpeg"
+        assert served.content == _jpeg_bytes()
+
+        detail = await async_client.get(f"/api/v1/library/files/{library_file.id}")
+        assert detail.json()["photos"] == [filename]
+
+        deleted = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
+        assert deleted.status_code == 200
+        assert deleted.json() == {"status": "deleted", "photos": []}
+        assert not (library_photos_dir(library_file.id) / filename).exists()
+
+        gone = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/{filename}")
+        assert gone.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_file_takes_photos_too(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
+
+        upload = await _upload(async_client, library_file.id, name="shot.png")
+        assert upload.status_code == 200
+        assert (library_photos_dir(library_file.id) / upload.json()["filename"]).is_file()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unlisted_filename_is_404_even_when_on_disk(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        library_file = await file_factory()
+        photos_dir = library_photos_dir(library_file.id)
+        photos_dir.mkdir(parents=True)
+        (photos_dir / "stray.jpg").write_bytes(_jpeg_bytes())
+
+        response = await async_client.get(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
+        assert response.status_code == 404
+
+        response = await async_client.delete(f"/api/v1/library/files/{library_file.id}/photos/stray.jpg")
+        assert response.status_code == 404
+        assert (photos_dir / "stray.jpg").exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_traversal_filename_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
+        # Even a traversal-looking name that IS in the stored list never leaves
+        # the photo directory — the membership check is not the only guard.
+        library_file = await file_factory(photos=["../../secret.jpg"])
+        (isolated_storage / "archive" / "secret.jpg").parent.mkdir(parents=True, exist_ok=True)
+        (isolated_storage / "archive" / "secret.jpg").write_bytes(_jpeg_bytes())
+
+        response = await async_client.get(
+            f"/api/v1/library/files/{library_file.id}/photos/..%2F..%2Fsecret.jpg",
+        )
+        assert response.status_code in (400, 404)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wrong_extension_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory()
+
+        response = await _upload(async_client, library_file.id, name="notes.txt", content=b"hello")
+        assert response.status_code == 400
+        assert not library_photos_dir(library_file.id).exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_oversized_upload_is_rejected(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory()
+
+        response = await _upload(async_client, library_file.id, content=b"\xff" * (10 * 1024 * 1024 + 1))
+        assert response.status_code == 413
+        assert not library_photos_dir(library_file.id).exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_to_missing_file_is_404(self, async_client: AsyncClient, isolated_storage):
+        response = await _upload(async_client, 999999)
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_trash_purge_removes_photo_dir(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        library_file = await file_factory()
+        upload = await _upload(async_client, library_file.id)
+        assert upload.status_code == 200
+        photos_dir = library_photos_dir(library_file.id)
+        assert photos_dir.is_dir()
+
+        trashed = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
+        assert trashed.status_code == 200
+        # Soft-delete keeps the photos, like the file bytes and thumbnail.
+        assert photos_dir.is_dir()
+
+        purged = await async_client.delete(f"/api/v1/library/trash/{library_file.id}")
+        assert purged.status_code == 200
+        assert not photos_dir.exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_file_delete_removes_photo_dir(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(is_external=True, file_path="/mnt/nas/part.stl", file_type="stl")
+        upload = await _upload(async_client, library_file.id)
+        assert upload.status_code == 200
+        photos_dir = library_photos_dir(library_file.id)
+
+        response = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
+        assert response.status_code == 200
+        assert response.json()["trashed"] is False
+        assert not photos_dir.exists()
+
+
+class TestPhotoDirectoryCleanup:
+    """Every path that hard-deletes a library row takes its photos with it.
+
+    The upload/delete round-trip, the trash purge and the external single-file
+    delete are covered above; these are the remaining ones — folder delete,
+    bulk delete of files and of whole folders, the external-folder scan that
+    drops rows for files that vanished from the share, and the admin user
+    delete that takes the user's items with them.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_delete_removes_photo_dir(self, async_client: AsyncClient, file_factory, isolated_storage):
+        folder = await async_client.post("/api/v1/library/folders", json={"name": "Brackets"})
+        assert folder.status_code == 200
+        folder_id = folder.json()["id"]
+        library_file = await file_factory(folder_id=folder_id)
+        assert (await _upload(async_client, library_file.id)).status_code == 200
+        photos_dir = library_photos_dir(library_file.id)
+        assert photos_dir.is_dir()
+
+        response = await async_client.delete(f"/api/v1/library/folders/{folder_id}")
+        assert response.status_code == 200
+        assert not photos_dir.exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_delete_removes_photo_dir_of_hard_deleted_file(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        # External files bypass the trash, so bulk delete hard-deletes them;
+        # a managed file is only soft-deleted and keeps its photos until the
+        # sweeper runs.
+        external = await file_factory(is_external=True, file_path="/mnt/nas/ext.stl", file_type="stl")
+        managed = await file_factory()
+        for library_file in (external, managed):
+            assert (await _upload(async_client, library_file.id)).status_code == 200
+
+        response = await async_client.post(
+            "/api/v1/library/bulk-delete",
+            json={"file_ids": [external.id, managed.id], "folder_ids": []},
+        )
+        assert response.status_code == 200
+        assert response.json()["deleted_files"] == 2
+        assert not library_photos_dir(external.id).exists()
+        assert library_photos_dir(managed.id).is_dir()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_delete_removes_photo_dirs_under_a_deleted_folder(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        # The folder branch of bulk-delete lets the cascade hard-delete every
+        # row in the subtree, so it owes the same photo cleanup the file
+        # branch above it does — including files nested a level down.
+        parent = await async_client.post("/api/v1/library/folders", json={"name": "Jigs"})
+        assert parent.status_code == 200
+        parent_id = parent.json()["id"]
+        child = await async_client.post("/api/v1/library/folders", json={"name": "V2", "parent_id": parent_id})
+        assert child.status_code == 200
+
+        top_file = await file_factory(folder_id=parent_id)
+        nested_file = await file_factory(folder_id=child.json()["id"])
+        for library_file in (top_file, nested_file):
+            assert (await _upload(async_client, library_file.id)).status_code == 200
+            assert library_photos_dir(library_file.id).is_dir()
+
+        response = await async_client.post(
+            "/api/v1/library/bulk-delete",
+            json={"file_ids": [], "folder_ids": [parent_id]},
+        )
+        assert response.status_code == 200
+        assert response.json()["deleted_folders"] == 1
+        assert (await async_client.get(f"/api/v1/library/files/{top_file.id}")).status_code == 404
+        assert not library_photos_dir(top_file.id).exists()
+        assert not library_photos_dir(nested_file.id).exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_user_with_their_items_removes_photo_dirs(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        # DELETE /users/{id}?delete_items=true bulk-deletes the rows, which is
+        # a hard delete like any other and owes the photos with it.
+        owner = User(username="photo-owner", password_hash="x", role="user")
+        db_session.add(owner)
+        await db_session.commit()
+        await db_session.refresh(owner)
+
+        owned = await file_factory(created_by_id=owner.id)
+        someone_elses = await file_factory()
+        for library_file in (owned, someone_elses):
+            assert (await _upload(async_client, library_file.id)).status_code == 200
+            assert library_photos_dir(library_file.id).is_dir()
+
+        response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
+        assert response.status_code == 204
+        assert (await async_client.get(f"/api/v1/library/files/{owned.id}")).status_code == 404
+        assert not library_photos_dir(owned.id).exists()
+        assert library_photos_dir(someone_elses.id).is_dir()
+
+    @pytest.fixture
+    def external_share(self, monkeypatch, tmp_path):
+        """Bambuddy's data dir and an opted-in external share, as siblings.
+
+        The share cannot live under ``base_dir`` — ``_validate_external_path``
+        refuses to mount a Bambuddy-managed directory, and the module's
+        ``isolated_storage`` points ``base_dir`` at ``tmp_path`` itself.
+        """
+        data_dir = tmp_path / "data"
+        data_dir.mkdir()
+        monkeypatch.setattr(app_settings, "base_dir", data_dir)
+        monkeypatch.setattr(app_settings, "archive_dir", data_dir / "archive")
+        share = tmp_path / "share"
+        share.mkdir()
+        monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(share))
+        return share
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_scan_removes_photo_dir_of_vanished_file(self, async_client: AsyncClient, external_share):
+        share = external_share
+        (share / "bracket.stl").write_bytes(b"fakestl")
+
+        folder = await async_client.post(
+            "/api/v1/library/folders/external",
+            json={"name": "Share", "external_path": str(share), "readonly": True, "show_hidden": False},
+        )
+        assert folder.status_code == 200
+        folder_id = folder.json()["id"]
+
+        scan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
+        assert scan.status_code == 200
+        assert scan.json()["added"] == 1
+
+        listing = await async_client.get(f"/api/v1/library/files?folder_id={folder_id}")
+        file_id = listing.json()[0]["id"]
+        assert (await _upload(async_client, file_id)).status_code == 200
+        photos_dir = library_photos_dir(file_id)
+        assert photos_dir.is_dir()
+
+        (share / "bracket.stl").unlink()
+
+        rescan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
+        assert rescan.status_code == 200
+        assert rescan.json()["removed"] == 1
+        assert not photos_dir.exists()

+ 171 - 0
backend/tests/integration/test_library_preview_thumbnail_api.py

@@ -0,0 +1,171 @@
+"""Integration tests for the client-rendered preview thumbnail upload (#2976).
+
+STEP/PDF/spreadsheet previews render in the browser and post their first
+render to POST /library/files/{id}/preview-thumbnail. These tests pin the
+endpoint's contract: PNG-only, capped size, only for the client-preview file
+types, and never replacing an existing thumbnail.
+"""
+
+import io
+
+import pytest
+from httpx import AsyncClient
+from PIL import Image
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.library import LibraryFile
+
+
+def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
+    buf = io.BytesIO()
+    Image.new("RGB", size, color).save(buf, "PNG")
+    return buf.getvalue()
+
+
+@pytest.fixture
+def isolated_storage(monkeypatch, tmp_path):
+    """Point thumbnail storage at a throwaway directory."""
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+    return tmp_path
+
+
+@pytest.fixture
+async def file_factory(db_session):
+    """Factory for LibraryFile rows of arbitrary file_type."""
+    _counter = [0]
+
+    async def _create_file(**kwargs):
+        _counter[0] += 1
+        counter = _counter[0]
+        defaults = {
+            "filename": f"part{counter}.step",
+            "file_path": f"library/files/part{counter}.step",
+            "file_type": "step",
+            "file_size": 100,
+        }
+        defaults.update(kwargs)
+        library_file = LibraryFile(**defaults)
+        db_session.add(library_file)
+        await db_session.commit()
+        await db_session.refresh(library_file)
+        return library_file
+
+    return _create_file
+
+
+class TestPreviewThumbnailUpload:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_sets_thumbnail_path(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="step")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+        assert response.status_code == 200
+        assert response.json() == {"updated": True}
+
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path
+        stored = isolated_storage / library_file.thumbnail_path
+        assert stored.exists()
+        with Image.open(stored) as img:
+            assert img.format == "PNG"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_downscales_oversized_image(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_bytes(size=(1024, 1024)), "image/png")},
+        )
+        assert response.status_code == 200
+
+        await db_session.refresh(library_file)
+        with Image.open(isolated_storage / library_file.thumbnail_path) as img:
+            assert max(img.size) <= 512
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_skips_when_thumbnail_exists(
+        self, async_client: AsyncClient, db_session, file_factory, isolated_storage
+    ):
+        library_file = await file_factory(file_type="csv", thumbnail_path="archive/library/thumbnails/existing.png")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+        assert response.status_code == 200
+        assert response.json() == {"updated": False}
+
+        await db_session.refresh(library_file)
+        assert library_file.thumbnail_path == "archive/library/thumbnails/existing.png"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejected_for_server_rendered_types(
+        self, async_client: AsyncClient, file_factory, isolated_storage
+    ):
+        # STL thumbnails are generated server-side; the client route must not
+        # be able to overwrite them.
+        library_file = await file_factory(file_type="stl", filename="part.stl")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_non_png(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory(file_type="step")
+
+        buf = io.BytesIO()
+        Image.new("RGB", (64, 64), "blue").save(buf, "JPEG")
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", buf.getvalue(), "image/png")},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_garbage_bytes(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory(file_type="xlsx")
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", b"not an image at all", "image/png")},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_rejects_oversized_payload(self, async_client: AsyncClient, file_factory, isolated_storage):
+        library_file = await file_factory(file_type="ods")
+
+        oversized = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024)
+        response = await async_client.post(
+            f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
+            files={"thumbnail": ("preview.png", oversized, "image/png")},
+        )
+        assert response.status_code == 413
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_missing_file_returns_404(self, async_client: AsyncClient, isolated_storage):
+        response = await async_client.post(
+            "/api/v1/library/files/999999/preview-thumbnail",
+            files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
+        )
+        assert response.status_code == 404

+ 108 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -944,6 +944,114 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
 
 
         assert response.status_code == 403
         assert response.status_code == 403
 
 
+    # ========================================================================
+    # Photo routes (#3077). Upload and delete are gated on LIBRARY_UPDATE_*,
+    # so a non-owner is refused with 403 exactly like ``update_file``. The
+    # read path goes through ``_ensure_library_file_visible`` and answers 404
+    # instead, so an id that exists tells an outsider nothing.
+    # ========================================================================
+
+    @pytest.fixture
+    def photo_storage(self, monkeypatch, tmp_path):
+        """Keep uploaded photos out of the real data directory."""
+        from backend.app.core.config import settings as app_settings
+
+        monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+        return tmp_path
+
+    @staticmethod
+    def _photo_upload():
+        import io
+
+        from PIL import Image
+
+        buf = io.BytesIO()
+        Image.new("RGB", (8, 8), "blue").save(buf, "JPEG")
+        return {"file": ("result.jpg", buf.getvalue(), "image/jpeg")}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_upload_photo_to_own_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
+    ):
+        file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{file.id}/photos",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files=self._photo_upload(),
+        )
+
+        assert response.status_code == 200
+        assert len(response.json()["photos"]) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_upload_photo_to_others_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
+    ):
+        from backend.app.utils.library_paths import library_photos_dir
+
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            f"/api/v1/library/files/{file.id}/photos",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            files=self._photo_upload(),
+        )
+
+        assert response.status_code == 403
+        assert not library_photos_dir(file.id).exists()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_photo_from_others_library_file(
+        self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
+    ):
+        from backend.app.utils.library_paths import library_photos_dir
+
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        upload = await async_client.post(
+            f"/api/v1/library/files/{file.id}/photos",
+            headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
+            files=self._photo_upload(),
+        )
+        filename = upload.json()["filename"]
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{file.id}/photos/{filename}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+        assert (library_photos_dir(file.id) / filename).is_file()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_reading_others_library_file_photo_gets_404(
+        self, async_client: AsyncClient, auth_setup, library_file_factory, photo_storage
+    ):
+        file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
+        upload = await async_client.post(
+            f"/api/v1/library/files/{file.id}/photos",
+            headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
+            files=self._photo_upload(),
+        )
+        filename = upload.json()["filename"]
+
+        owner = await async_client.get(
+            f"/api/v1/library/files/{file.id}/photos/{filename}",
+            headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
+        )
+        assert owner.status_code == 200
+
+        stranger = await async_client.get(
+            f"/api/v1/library/files/{file.id}/photos/{filename}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+        assert stranger.status_code == 404
+
     # ========================================================================
     # ========================================================================
     # Folder deletion (#1781): folders have no ownership tracking, so users
     # Folder deletion (#1781): folders have no ownership tracking, so users
     # with only library:delete_own may delete empty, non-external, non-linked
     # with only library:delete_own may delete empty, non-external, non-linked

+ 55 - 0
backend/tests/integration/test_security_headers.py

@@ -289,6 +289,61 @@ async def test_spa_csp_nonce_changes_per_request(async_client: AsyncClient):
     assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
     assert len(nonces) == 5, f"nonces should be per-request, got {nonces!r}"
 
 
 
 
+# ─── #2976: STEP preview needs WebAssembly, and only WebAssembly ─────────
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_spa_csp_allows_wasm_but_not_eval(async_client: AsyncClient):
+    """script-src must carry 'wasm-unsafe-eval' but never 'unsafe-eval' (#2976).
+
+    The STEP preview triangulates in the browser via OpenCascade compiled to
+    WASM; without 'wasm-unsafe-eval' the nonce-based CSP blocks
+    WebAssembly.instantiate() and the preview dies with a CompileError.
+    'wasm-unsafe-eval' permits wasm compilation only — JS eval()/Function()
+    stay blocked, which is what the second assertion pins.
+    """
+    resp = await async_client.get("/api/v1/auth/status")
+    csp = resp.headers.get("Content-Security-Policy", "")
+    script_src = next(
+        (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
+        "",
+    )
+    assert "'wasm-unsafe-eval'" in script_src, f"script-src must allow wasm compilation: {script_src!r}"
+    # Substring check must not be fooled by 'wasm-unsafe-eval' containing
+    # "unsafe-eval" — compare whole tokens.
+    tokens = script_src.split()
+    assert "'unsafe-eval'" not in tokens, f"script-src must not allow JS eval: {script_src!r}"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_step_worker_asset_csp_relaxes_eval_only_for_that_file(async_client: AsyncClient):
+    """Only the STEP worker script's own response may carry 'unsafe-eval' (#2976).
+
+    The occt-import-js embind glue generates invokers with `new Function`,
+    so the dedicated worker needs an eval-permitting policy. Per CSP3 a
+    worker is governed by the policy delivered with its own script response,
+    which confines eval to that DOM-less context. Any other asset — and the
+    SPA document itself — must stay nonce-strict. Both requests 404 in the
+    test checkout; the security middleware stamps headers regardless.
+    """
+
+    def script_src_tokens(resp) -> list[str]:
+        csp = resp.headers.get("Content-Security-Policy", "")
+        directive = next(
+            (d.strip() for d in csp.split(";") if d.strip().startswith("script-src")),
+            "",
+        )
+        return directive.split()
+
+    worker = await async_client.get("/assets/stepPreview.worker-Ck9aB12c.js")
+    assert "'unsafe-eval'" in script_src_tokens(worker), "step worker script must be allowed to eval"
+
+    other = await async_client.get("/assets/index-Ck9aB12c.js")
+    assert "'unsafe-eval'" not in script_src_tokens(other), "ordinary assets must stay eval-free"
+
+
 # ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
 # ─── #1460: HEAD on PWA bootstrap routes (manifest / sw / sw-register) ───
 
 
 
 

+ 67 - 0
backend/tests/unit/test_library_photo_move.py

@@ -0,0 +1,67 @@
+"""``move_library_photos`` edge cases (#3077).
+
+The happy path is pinned by the scheduler's cleanup tests; this covers what
+they cannot reach — a name already taken in the destination, and a move that
+fails halfway.
+"""
+
+import pytest
+
+from backend.app.core.config import settings
+from backend.app.utils.library_paths import library_photos_dir, move_library_photos
+
+
+@pytest.fixture
+def photo_dirs(monkeypatch, tmp_path):
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    destination = tmp_path / "archives" / "1" / "photos"
+    source = library_photos_dir(7)
+    source.mkdir(parents=True)
+    return source, destination
+
+
+def test_moves_every_photo_and_drops_the_directory(photo_dirs):
+    source, destination = photo_dirs
+    (source / "a1b2c3d4.jpg").write_bytes(b"one")
+    (source / "e5f6a7b8.png").write_bytes(b"two")
+
+    moved = move_library_photos(7, ["a1b2c3d4.jpg", "e5f6a7b8.png"], destination)
+
+    assert moved == ["a1b2c3d4.jpg", "e5f6a7b8.png"]
+    assert (destination / "a1b2c3d4.jpg").read_bytes() == b"one"
+    assert not source.exists()
+
+
+def test_renames_around_a_name_the_destination_already_holds(photo_dirs):
+    source, destination = photo_dirs
+    (source / "a1b2c3d4.jpg").write_bytes(b"library")
+    destination.mkdir(parents=True)
+    (destination / "a1b2c3d4.jpg").write_bytes(b"archive")
+
+    moved = move_library_photos(7, ["a1b2c3d4.jpg"], destination)
+
+    assert moved != ["a1b2c3d4.jpg"]
+    assert moved[0].endswith(".jpg")
+    assert (destination / "a1b2c3d4.jpg").read_bytes() == b"archive"
+    assert (destination / moved[0]).read_bytes() == b"library"
+
+
+def test_a_traversal_name_is_skipped_and_keeps_the_directory(photo_dirs):
+    # A stored name is uuid-generated, so this only happens to a row someone
+    # has written to by hand — but the photos are then left alone rather than
+    # swept away by a cleanup that could not move them.
+    source, destination = photo_dirs
+    (source / "a1b2c3d4.jpg").write_bytes(b"one")
+
+    moved = move_library_photos(7, ["../escape.jpg", "a1b2c3d4.jpg"], destination)
+
+    assert moved == ["a1b2c3d4.jpg"]
+    assert source.is_dir()
+
+
+def test_a_file_without_photos_is_a_no_op(photo_dirs):
+    source, destination = photo_dirs
+    source.rmdir()
+
+    assert move_library_photos(7, [], destination) == []
+    assert not destination.exists()

+ 1 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -607,6 +607,7 @@ NOT_A_FETCH_TARGET = {
     ("SystemConfigRequest", "backend_url"),
     ("SystemConfigRequest", "backend_url"),
     ("ExternalLinkCreate", "url"),  # sidebar link, rendered in the UI, never requested
     ("ExternalLinkCreate", "url"),  # sidebar link, rendered in the UI, never requested
     ("ExternalLinkUpdate", "url"),
     ("ExternalLinkUpdate", "url"),
+    ("FileUpdate", "external_url"),  # library file link (#3077), rendered in the UI, never fetched
     ("MaintenanceTypeCreate", "wiki_url"),  # documentation link surfaced in the UI/notifications
     ("MaintenanceTypeCreate", "wiki_url"),  # documentation link surfaced in the UI/notifications
     ("MaintenanceTypeUpdate", "wiki_url"),
     ("MaintenanceTypeUpdate", "wiki_url"),
     ("ArchiveUpdate", "external_url"),  # stored source link for the model, never fetched
     ("ArchiveUpdate", "external_url"),  # stored source link for the model, never fetched

+ 228 - 3
backend/tests/unit/test_scheduler_cleanup_library.py

@@ -1,10 +1,12 @@
+import logging
 from contextlib import ExitStack
 from contextlib import ExitStack
 from pathlib import Path
 from pathlib import Path
 from types import SimpleNamespace
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
-from sqlalchemy import select
+from sqlalchemy import event, select
+from sqlalchemy.exc import OperationalError
 from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
 from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
 
 
 import backend.app.models  # noqa: F401 - populate Base.metadata
 import backend.app.models  # noqa: F401 - populate Base.metadata
@@ -15,6 +17,8 @@ from backend.app.models.library import LibraryFile
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.services.print_scheduler import PrintScheduler
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.utils.archive_paths import archive_photos_dir
+from backend.app.utils.library_paths import library_photos_dir
 from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 from backend.tests._fixtures.background_tasks import discarding_spawn_patch
 
 
 
 
@@ -27,12 +31,13 @@ async def queue_factory(tmp_path):
     session_maker = async_sessionmaker(engine, expire_on_commit=False)
     session_maker = async_sessionmaker(engine, expire_on_commit=False)
     case_counter = 0
     case_counter = 0
 
 
-    async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=()):
+    async def make_case(*, cleanup=True, is_external=False, thumbnail_path=None, siblings=(), photos=()):
         nonlocal case_counter
         nonlocal case_counter
         case_counter += 1
         case_counter += 1
 
 
         base_dir = tmp_path / f"case-{case_counter}"
         base_dir = tmp_path / f"case-{case_counter}"
         base_dir.mkdir()
         base_dir.mkdir()
+        archive_dir = base_dir / "archive"
         source_path = base_dir / "library" / f"source-{case_counter}.3mf"
         source_path = base_dir / "library" / f"source-{case_counter}.3mf"
         source_path.parent.mkdir()
         source_path.parent.mkdir()
         source_path.write_bytes(b"library source")
         source_path.write_bytes(b"library source")
@@ -70,10 +75,21 @@ async def queue_factory(tmp_path):
                 thumbnail_path=thumbnail_db_path,
                 thumbnail_path=thumbnail_db_path,
                 file_metadata=None,
                 file_metadata=None,
                 is_external=is_external,
                 is_external=is_external,
+                photos=list(photos) or None,
             )
             )
             db.add_all([printer, library_file])
             db.add_all([printer, library_file])
             await db.flush()
             await db.flush()
 
 
+            # Photos of the printed result (#3077). Written through the real
+            # helper so the test cannot drift from the layout the code uses.
+            photos_dir = None
+            if photos:
+                with patch.object(scheduler_module.settings, "archive_dir", archive_dir):
+                    photos_dir = library_photos_dir(library_file.id)
+                photos_dir.mkdir(parents=True, exist_ok=True)
+                for name in photos:
+                    (photos_dir / name).write_bytes(f"photo {name}".encode())
+
             item = PrintQueueItem(
             item = PrintQueueItem(
                 printer_id=printer.id,
                 printer_id=printer.id,
                 library_file_id=library_file.id,
                 library_file_id=library_file.id,
@@ -152,7 +168,10 @@ async def queue_factory(tmp_path):
             return SimpleNamespace(
             return SimpleNamespace(
                 session_maker=session_maker,
                 session_maker=session_maker,
                 base_dir=base_dir,
                 base_dir=base_dir,
+                archive_dir=archive_dir,
                 source_path=source_path,
                 source_path=source_path,
+                photos_dir=photos_dir,
+                photo_names=list(photos),
                 thumbnail_path=thumbnail_actual_path,
                 thumbnail_path=thumbnail_actual_path,
                 printer_id=printer.id,
                 printer_id=printer.id,
                 library_file_id=library_file.id,
                 library_file_id=library_file.id,
@@ -170,7 +189,14 @@ async def queue_factory(tmp_path):
         await engine.dispose()
         await engine.dispose()
 
 
 
 
-async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effect=None):
+async def _dispatch_library_item(
+    ctx,
+    *,
+    archive_failure=False,
+    unlink_side_effect=None,
+    cleanup_commit_failure=False,
+    photo_commit_failure=False,
+):
     scheduler = PrintScheduler()
     scheduler = PrintScheduler()
 
 
     async def archive_print(
     async def archive_print(
@@ -213,6 +239,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
 
 
     patches = [
     patches = [
         patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
         patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+        patch.object(scheduler_module.settings, "archive_dir", ctx.archive_dir),
         patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
         patch("backend.app.services.archive.ArchiveService.archive_print", new=archive_print),
         patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
         patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
         patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
         patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
@@ -239,10 +266,99 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
             stack.enter_context(patcher)
             stack.enter_context(patcher)
 
 
         async with ctx.session_maker() as db:
         async with ctx.session_maker() as db:
+            if cleanup_commit_failure:
+                _arm_commit_failure_on_library_delete(db)
+            if photo_commit_failure:
+                _arm_commit_failure_on_photo_move(db, stack)
             item = await db.get(PrintQueueItem, ctx.queue_item_id)
             item = await db.get(PrintQueueItem, ctx.queue_item_id)
             await scheduler._start_print(db, item)
             await scheduler._start_print(db, item)
 
 
 
 
+def _fail_inside_the_next_flush(db, statement):
+    """Make the next flush on this session fail, once, the way SQLite does.
+
+    Raising *instead of* calling `db.commit()` does not reproduce a failed
+    commit and is not the dangerous case: the session stays ACTIVE, nothing is
+    expired, and every loaded instance still reads out of `__dict__`. What a
+    busy writer actually gives you is a statement error raised inside the
+    flush — SQLite takes the write lock at the first DML statement, not at
+    COMMIT, so "database is locked" surfaces there (#1853). SQLAlchemy rolls
+    that back internally through `safe_reraise` before re-raising, which
+    expires every loaded instance and leaves the session in pending-rollback
+    state: the next ORM attribute read raises PendingRollbackError, *before*
+    the handler's own rollback can run. That is the state a handler on this
+    path has to survive, so it is the state these tests have to produce.
+    """
+    sync_session = db.sync_session
+    fired = False
+
+    def after_flush(session, flush_context):
+        nonlocal fired
+        if fired:
+            return
+        fired = True
+        raise OperationalError(statement, {}, Exception("database is locked"))
+
+    event.listen(sync_session, "after_flush", after_flush)
+
+
+def _arm_commit_failure_on_library_delete(db):
+    """Make the one commit that removes the library row fail, once.
+
+    Stands in for the "database is locked" cascades the commit's own comment
+    cites (#1853). Armed by the delete rather than by a call count so it
+    cannot drift onto a different commit.
+    """
+    original_delete = db.delete
+    original_commit = db.commit
+    armed = False
+
+    async def delete(obj):
+        nonlocal armed
+        if isinstance(obj, LibraryFile):
+            armed = True
+        return await original_delete(obj)
+
+    async def commit():
+        nonlocal armed
+        if armed:
+            armed = False
+            _fail_inside_the_next_flush(db, "DELETE FROM library_files WHERE library_files.id = ?")
+        return await original_commit()
+
+    db.delete = delete
+    db.commit = commit
+
+
+def _arm_commit_failure_on_photo_move(db, stack):
+    """Make the commit that records the carried photos fail, once.
+
+    The second commit of this path (#3077): the archive and the delete are
+    already committed, the pictures are already on disk under the archive,
+    and only `archive.photos` is pending. Armed by the move itself so it
+    cannot drift onto the delete's commit.
+    """
+    original_commit = db.commit
+    original_move = scheduler_module.move_library_photos
+    armed = False
+
+    def move_library_photos(file_id, photos, destination):
+        nonlocal armed
+        carried = original_move(file_id, photos, destination)
+        armed = bool(carried)
+        return carried
+
+    async def commit():
+        nonlocal armed
+        if armed:
+            armed = False
+            _fail_inside_the_next_flush(db, "UPDATE print_archives SET photos=? WHERE print_archives.id = ?")
+        return await original_commit()
+
+    stack.enter_context(patch.object(scheduler_module, "move_library_photos", move_library_photos))
+    db.commit = commit
+
+
 async def _queue_snapshot(ctx):
 async def _queue_snapshot(ctx):
     async with ctx.session_maker() as db:
     async with ctx.session_maker() as db:
         item = await db.get(PrintQueueItem, ctx.queue_item_id)
         item = await db.get(PrintQueueItem, ctx.queue_item_id)
@@ -279,6 +395,115 @@ async def test_external_library_file_skips_cleanup(queue_factory):
     assert ctx.source_path.exists()
     assert ctx.source_path.exists()
 
 
 
 
+@pytest.mark.asyncio
+async def test_cleanup_moves_the_photos_into_the_archive(queue_factory):
+    """Photos follow the consumed file into the archive that replaces it (#3077).
+
+    The row is hard-deleted here, so leaving the photo directory alone
+    orphaned it under an id nothing points at any more — and the pictures
+    of a print that still has a record disappeared from the UI.
+    """
+    ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg", "e5f6a7b8.png"])
+
+    await _dispatch_library_item(ctx)
+
+    _, library_file, archive = await _queue_snapshot(ctx)
+    assert library_file is None
+    assert not ctx.photos_dir.exists()
+    assert archive.photos == ctx.photo_names
+    with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
+        destination = archive_photos_dir(archive)
+    for name in ctx.photo_names:
+        assert (destination / name).read_bytes() == f"photo {name}".encode()
+
+
+@pytest.mark.asyncio
+async def test_external_library_file_keeps_its_photos(queue_factory):
+    ctx = await queue_factory(cleanup=True, is_external=True, photos=["a1b2c3d4.jpg"])
+
+    await _dispatch_library_item(ctx)
+
+    _, library_file, archive = await _queue_snapshot(ctx)
+    assert library_file is not None
+    assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
+    assert archive.photos is None
+
+
+@pytest.mark.asyncio
+async def test_archive_creation_failure_keeps_the_photos(queue_factory):
+    ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
+
+    await _dispatch_library_item(ctx, archive_failure=True)
+
+    _, library_file, archive = await _queue_snapshot(ctx)
+    assert archive is None
+    assert library_file is not None
+    assert (ctx.photos_dir / "a1b2c3d4.jpg").is_file()
+
+
+@pytest.mark.asyncio
+async def test_cleanup_commit_failure_keeps_the_photos_with_the_library_file(queue_factory):
+    """The photos move after the delete commits, not before it (#3077).
+
+    The commit that removes the library row can fail; the except branch rolls
+    it back and the file is in the library again. Photos moved ahead of that
+    commit would be gone from under it — the row would name a directory that
+    no longer exists, and the pictures would sit under an archive that was
+    rolled back too.
+    """
+    ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
+
+    await _dispatch_library_item(ctx, cleanup_commit_failure=True)
+
+    item, library_file, archive = await _queue_snapshot(ctx)
+    assert item.status == "failed"
+    assert archive is None
+    assert library_file is not None
+    assert library_file.photos == ["a1b2c3d4.jpg"]
+    assert (ctx.photos_dir / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
+    assert not (ctx.base_dir / "archives" / "photos").exists()
+
+
+@pytest.mark.asyncio
+async def test_photo_commit_failure_still_dispatches_the_print(queue_factory, caplog):
+    """A failed photos commit must not take the dispatch down with it (#3077).
+
+    The archive and the delete are committed by then, so the print goes ahead
+    and the pictures sit unnamed under the archive.
+
+    The commit fails inside the flush, which is where a locked SQLite fails —
+    see `_fail_inside_the_next_flush`. That expires every loaded instance
+    twice over: once by SQLAlchemy's internal rollback, before the handler
+    runs at all, and again at the handler's own `rollback()`. So the handler
+    may not read an ORM attribute on either side of that rollback. Before it,
+    a read raises PendingRollbackError; after it, MissingGreenlet — the nozzle
+    guard's `archive.nozzle_diameter` and the upload's `printer.name` are the
+    ones that used to die.
+    """
+    ctx = await queue_factory(cleanup=True, photos=["a1b2c3d4.jpg"])
+
+    with caplog.at_level(logging.WARNING, logger="backend.app.services.print_scheduler"):
+        await _dispatch_library_item(ctx, photo_commit_failure=True)
+
+    item, library_file, archive = await _queue_snapshot(ctx)
+    # The handler absorbed it rather than the failure being skipped: it names
+    # the queue item and the archive from ints it held before the commit.
+    assert any(
+        f"Queue item {ctx.queue_item_id}: failed to carry library photos into archive {item.archive_id}"
+        in record.message
+        for record in caplog.records
+    )
+    assert item.status == "printing"
+    assert item.archive_id == archive.id
+    assert library_file is None
+    assert not archive.photos
+    ctx.upload.assert_awaited()
+    ctx.start_print.assert_called()
+    with patch.object(scheduler_module.settings, "base_dir", ctx.base_dir):
+        destination = archive_photos_dir(archive)
+    assert (destination / "a1b2c3d4.jpg").read_bytes() == b"photo a1b2c3d4.jpg"
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
 async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
     ctx = await queue_factory(cleanup=True, thumbnail_path="relative")
     ctx = await queue_factory(cleanup=True, thumbnail_path="relative")

+ 317 - 1
frontend/package-lock.json

@@ -35,6 +35,9 @@
         "micromark-extension-gfm-strikethrough": "^2.1.0",
         "micromark-extension-gfm-strikethrough": "^2.1.0",
         "micromark-extension-gfm-table": "^2.1.1",
         "micromark-extension-gfm-table": "^2.1.1",
         "micromark-extension-gfm-task-list-item": "^2.1.0",
         "micromark-extension-gfm-task-list-item": "^2.1.0",
+        "occt-import-js": "^0.0.23",
+        "papaparse": "^5.7.0",
+        "pdfjs-dist": "^6.2.108",
         "qrcode.react": "^4.2.0",
         "qrcode.react": "^4.2.0",
         "react": "^19.2.0",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
         "react-dom": "^19.2.0",
@@ -43,7 +46,8 @@
         "react-router-dom": "7.18.2",
         "react-router-dom": "7.18.2",
         "react-simple-keyboard": "^3.8.164",
         "react-simple-keyboard": "^3.8.164",
         "recharts": "^3.5.1",
         "recharts": "^3.5.1",
-        "three": "^0.181.2"
+        "three": "^0.181.2",
+        "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@eslint/js": "^9.39.1",
         "@eslint/js": "^9.39.1",
@@ -52,6 +56,7 @@
         "@testing-library/react": "^16.0.0",
         "@testing-library/react": "^16.0.0",
         "@testing-library/user-event": "^14.5.0",
         "@testing-library/user-event": "^14.5.0",
         "@types/node": "^24.10.1",
         "@types/node": "^24.10.1",
+        "@types/papaparse": "^5.5.2",
         "@types/react": "^19.2.5",
         "@types/react": "^19.2.5",
         "@types/react-dom": "^19.2.3",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^5.2.0",
         "@vitejs/plugin-react": "^5.2.0",
@@ -1006,6 +1011,271 @@
         "node": ">=18"
         "node": ">=18"
       }
       }
     },
     },
+    "node_modules/@napi-rs/canvas": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz",
+      "integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==",
+      "license": "MIT",
+      "optional": true,
+      "workspaces": [
+        "e2e/*"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "optionalDependencies": {
+        "@napi-rs/canvas-android-arm64": "1.0.8",
+        "@napi-rs/canvas-darwin-arm64": "1.0.8",
+        "@napi-rs/canvas-darwin-x64": "1.0.8",
+        "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8",
+        "@napi-rs/canvas-linux-arm64-gnu": "1.0.8",
+        "@napi-rs/canvas-linux-arm64-musl": "1.0.8",
+        "@napi-rs/canvas-linux-riscv64-gnu": "1.0.8",
+        "@napi-rs/canvas-linux-x64-gnu": "1.0.8",
+        "@napi-rs/canvas-linux-x64-musl": "1.0.8",
+        "@napi-rs/canvas-win32-arm64-msvc": "1.0.8",
+        "@napi-rs/canvas-win32-x64-msvc": "1.0.8"
+      }
+    },
+    "node_modules/@napi-rs/canvas-android-arm64": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz",
+      "integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-arm64": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz",
+      "integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-x64": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz",
+      "integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz",
+      "integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz",
+      "integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz",
+      "integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz",
+      "integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz",
+      "integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-musl": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz",
+      "integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz",
+      "integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz",
+      "integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
     "node_modules/@napi-rs/wasm-runtime": {
     "node_modules/@napi-rs/wasm-runtime": {
       "version": "1.1.5",
       "version": "1.1.5",
       "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
       "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -2461,6 +2731,16 @@
         "undici-types": "~7.16.0"
         "undici-types": "~7.16.0"
       }
       }
     },
     },
+    "node_modules/@types/papaparse": {
+      "version": "5.5.2",
+      "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
+      "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/react": {
     "node_modules/@types/react": {
       "version": "19.2.13",
       "version": "19.2.13",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
@@ -6307,6 +6587,12 @@
         "node": ">=12.20.0"
         "node": ">=12.20.0"
       }
       }
     },
     },
+    "node_modules/occt-import-js": {
+      "version": "0.0.23",
+      "resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz",
+      "integrity": "sha512-RFfYQXYFX5C1mB1Aywm0ShcUKzXOr/VzTnlzhBSDJOR6YCAPt1HYCzeXWg1vwwjn/cUxwqRNhhtf1dlewoZYCQ==",
+      "license": "LGPL-2.1"
+    },
     "node_modules/optionator": {
     "node_modules/optionator": {
       "version": "0.9.4",
       "version": "0.9.4",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6376,6 +6662,12 @@
       "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
       "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
       "license": "(MIT AND Zlib)"
       "license": "(MIT AND Zlib)"
     },
     },
+    "node_modules/papaparse": {
+      "version": "5.7.0",
+      "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.7.0.tgz",
+      "integrity": "sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==",
+      "license": "MIT"
+    },
     "node_modules/parent-module": {
     "node_modules/parent-module": {
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6459,6 +6751,18 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
+    "node_modules/pdfjs-dist": {
+      "version": "6.2.108",
+      "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
+      "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=22.13.0 || >=24"
+      },
+      "optionalDependencies": {
+        "@napi-rs/canvas": "^1.0.0"
+      }
+    },
     "node_modules/picocolors": {
     "node_modules/picocolors": {
       "version": "1.1.1",
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8349,6 +8653,18 @@
         }
         }
       }
       }
     },
     },
+    "node_modules/xlsx": {
+      "version": "0.20.3",
+      "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
+      "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/xml-name-validator": {
     "node_modules/xml-name-validator": {
       "version": "5.0.0",
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
       "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

+ 6 - 1
frontend/package.json

@@ -43,6 +43,9 @@
     "micromark-extension-gfm-strikethrough": "^2.1.0",
     "micromark-extension-gfm-strikethrough": "^2.1.0",
     "micromark-extension-gfm-table": "^2.1.1",
     "micromark-extension-gfm-table": "^2.1.1",
     "micromark-extension-gfm-task-list-item": "^2.1.0",
     "micromark-extension-gfm-task-list-item": "^2.1.0",
+    "occt-import-js": "^0.0.23",
+    "papaparse": "^5.7.0",
+    "pdfjs-dist": "^6.2.108",
     "qrcode.react": "^4.2.0",
     "qrcode.react": "^4.2.0",
     "react": "^19.2.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-dom": "^19.2.0",
@@ -51,7 +54,8 @@
     "react-router-dom": "7.18.2",
     "react-router-dom": "7.18.2",
     "react-simple-keyboard": "^3.8.164",
     "react-simple-keyboard": "^3.8.164",
     "recharts": "^3.5.1",
     "recharts": "^3.5.1",
-    "three": "^0.181.2"
+    "three": "^0.181.2",
+    "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
   },
   },
   "overrides": {
   "overrides": {
     "minimatch": "^10.2.1",
     "minimatch": "^10.2.1",
@@ -67,6 +71,7 @@
     "@testing-library/react": "^16.0.0",
     "@testing-library/react": "^16.0.0",
     "@testing-library/user-event": "^14.5.0",
     "@testing-library/user-event": "^14.5.0",
     "@types/node": "^24.10.1",
     "@types/node": "^24.10.1",
+    "@types/papaparse": "^5.5.2",
     "@types/react": "^19.2.5",
     "@types/react": "^19.2.5",
     "@types/react-dom": "^19.2.3",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^5.2.0",
     "@vitejs/plugin-react": "^5.2.0",

+ 1 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -222,6 +222,7 @@ const FR_COGNATES = [
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
   '{{filament}} @ {{temp}}°C',  // drying badge: filament code + universal °C
   'Simple', 'Expert',  // slicer settings visibility tiers — identical words in French
   'Simple', 'Expert',  // slicer settings visibility tiers — identical words in French
   'Support',  // same word in French
   'Support',  // same word in French
+  'Photos', '{{count}} photo', '{{count}} photos',  // file details photo strip (#3077) — same word in French
 ];
 ];
 
 
 // Italian cognates.
 // Italian cognates.

+ 324 - 0
frontend/src/__tests__/components/LibraryFileDetailsModal.test.tsx

@@ -0,0 +1,324 @@
+/**
+ * Tests for the LibraryFileDetailsModal component (#3077).
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { LibraryFileDetailsModal } from '../../components/LibraryFileDetailsModal';
+import type { LibraryFileListItem } from '../../api/client';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const listItem: LibraryFileListItem = {
+  id: 7,
+  folder_id: null,
+  is_external: false,
+  filename: 'benchy.gcode.3mf',
+  file_type: 'gcode.3mf',
+  file_size: 1048576,
+  thumbnail_path: null,
+  print_count: 2,
+  duplicate_count: 0,
+  created_by_id: null,
+  created_by_username: null,
+  created_at: '2024-01-01T00:00:00Z',
+  fs_modified_at: null,
+  print_name: 'Benchy',
+  print_time_seconds: 3600,
+  filament_used_grams: 12.5,
+  sliced_for_model: 'X1C',
+  tags: [],
+};
+
+const details = {
+  ...listItem,
+  folder_name: null,
+  project_id: null,
+  project_name: null,
+  file_path: 'library/files/benchy.gcode.3mf',
+  file_hash: null,
+  metadata: null,
+  last_printed_at: null,
+  notes: 'Print with brim',
+  external_url: 'https://www.printables.com/model/1',
+  photos: ['abc123.jpg'],
+  source_url: 'https://makerworld.com/models/42',
+  duplicates: null,
+  updated_at: '2024-02-01T00:00:00Z',
+};
+
+describe('LibraryFileDetailsModal', () => {
+  const onClose = vi.fn();
+  let lastUpdate: Record<string, unknown> | null = null;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    lastUpdate = null;
+    server.use(
+      http.get('/api/v1/library/files/7', () => HttpResponse.json(details)),
+      http.put('/api/v1/library/files/7', async ({ request }) => {
+        lastUpdate = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ ...details, ...lastUpdate });
+      }),
+      http.delete('/api/v1/library/files/7/photos/:filename', () =>
+        HttpResponse.json({ status: 'deleted', photos: [] })
+      )
+    );
+  });
+
+  it('renders the facts, notes, link and photos from the detail response', async () => {
+    render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+    expect(screen.getByText('benchy.gcode.3mf')).toBeInTheDocument();
+    // Header badge and the Type fact.
+    expect(screen.getAllByText('GCODE.3MF')).toHaveLength(2);
+    expect(screen.getByText('1.0 MB')).toBeInTheDocument();
+
+    await waitFor(() => {
+      expect(screen.getByDisplayValue('Print with brim')).toBeInTheDocument();
+    });
+    expect(screen.getByDisplayValue('https://www.printables.com/model/1')).toBeInTheDocument();
+    expect(screen.getByText('Benchy')).toBeInTheDocument();
+    expect(screen.getByText('X1C')).toBeInTheDocument();
+    expect(screen.getByText('12.5 g')).toBeInTheDocument();
+
+    // Source provenance is a link, not an editable field.
+    const source = screen.getByRole('link', { name: /makerworld\.com\/models\/42/ });
+    expect(source).toHaveAttribute('href', 'https://makerworld.com/models/42');
+    expect(source).toHaveAttribute('target', '_blank');
+
+    const photo = screen.getByAltText('Photos') as HTMLImageElement;
+    expect(photo.src).toContain('/library/files/7/photos/abc123.jpg');
+  });
+
+  it('saves edited notes and link through updateLibraryFile', async () => {
+    const user = userEvent.setup();
+    render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+    const notes = await screen.findByDisplayValue('Print with brim');
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    // Nothing changed yet, so there is nothing to save.
+    expect(saveButton).toBeDisabled();
+
+    await user.clear(notes);
+    await user.type(notes, 'Use 0.2 mm layers');
+    const link = screen.getByDisplayValue('https://www.printables.com/model/1');
+    await user.clear(link);
+    await user.type(link, 'https://example.com/part ');
+
+    expect(saveButton).toBeEnabled();
+    await user.click(saveButton);
+
+    await waitFor(() => {
+      expect(lastUpdate).toEqual({ notes: 'Use 0.2 mm layers', external_url: 'https://example.com/part' });
+    });
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+  });
+
+  it('sends an empty external_url when the link is cleared', async () => {
+    const user = userEvent.setup();
+    render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+    const link = await screen.findByDisplayValue('https://www.printables.com/model/1');
+    await user.clear(link);
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => {
+      expect(lastUpdate).toEqual({ notes: 'Print with brim', external_url: '' });
+    });
+  });
+
+  it('is read-only without edit permission', async () => {
+    render(<LibraryFileDetailsModal file={listItem} canEdit={false} onClose={onClose} />);
+
+    const notes = await screen.findByDisplayValue('Print with brim');
+    expect(notes).toBeDisabled();
+    expect(screen.getByDisplayValue('https://www.printables.com/model/1')).toBeDisabled();
+    expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Add photo')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Delete photo')).not.toBeInTheDocument();
+  });
+
+  it('removes a photo from the grid after deleting it', async () => {
+    const user = userEvent.setup();
+    render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+    await screen.findByAltText('Photos');
+    await user.click(screen.getByLabelText('Delete photo'));
+
+    await waitFor(() => {
+      expect(screen.queryByAltText('Photos')).not.toBeInTheDocument();
+    });
+  });
+
+  it('keeps unsaved notes when a photo change refetches the file', async () => {
+    // Deleting a photo invalidates the detail query; the refetched file has a
+    // different photo list, so it is a new object and must not reseed the form.
+    let serverPhotos = ['abc123.jpg'];
+    let detailFetches = 0;
+    server.use(
+      http.get('/api/v1/library/files/7', () => {
+        detailFetches += 1;
+        return HttpResponse.json({ ...details, photos: serverPhotos });
+      }),
+      http.delete('/api/v1/library/files/7/photos/:filename', () => {
+        serverPhotos = [];
+        return HttpResponse.json({ status: 'deleted', photos: [] });
+      })
+    );
+    const user = userEvent.setup();
+    render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+    const notes = await screen.findByDisplayValue('Print with brim');
+    await user.clear(notes);
+    await user.type(notes, 'Draft not saved yet');
+
+    await user.click(screen.getByLabelText('Delete photo'));
+    await waitFor(() => {
+      expect(screen.queryByAltText('Photos')).not.toBeInTheDocument();
+    });
+    await waitFor(() => expect(detailFetches).toBeGreaterThan(1));
+
+    expect(screen.getByDisplayValue('Draft not saved yet')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
+  });
+
+  describe('lightbox', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/library/files/7', () =>
+          HttpResponse.json({ ...details, photos: ['one.jpg', 'two.jpg', 'three.jpg'] })
+        )
+      );
+    });
+
+    it('opens on the photo that was clicked, not the first one', async () => {
+      const user = userEvent.setup();
+      render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+      const thumbnails = await screen.findAllByAltText('Photos');
+      expect(thumbnails).toHaveLength(3);
+      await user.click(thumbnails[2]);
+
+      expect(await screen.findByText('Photo 3 of 3')).toBeInTheDocument();
+      const shown = screen.getByAltText('Photo 3') as HTMLImageElement;
+      expect(shown.src).toContain('/library/files/7/photos/three.jpg');
+    });
+
+    it('keeps the details modal open when the delete confirmation is dismissed', async () => {
+      // The confirmation renders inside the details overlay, whose root closes
+      // on a backdrop click — dismissing the confirmation used to discard the
+      // unsaved notes and link along with it.
+      const user = userEvent.setup();
+      const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+      const notes = await screen.findByDisplayValue('Print with brim');
+      await user.clear(notes);
+      await user.type(notes, 'Draft not saved yet');
+
+      await user.click(screen.getAllByAltText('Photos')[0]);
+      await screen.findByText('Photo 1 of 3');
+      await user.click(container.querySelector('.text-red-400') as HTMLElement);
+
+      const confirmation = await screen.findByText('Delete Photo');
+      await user.click(confirmation.closest('div.fixed') as HTMLElement);
+
+      await waitFor(() => expect(screen.queryByText('Delete Photo')).not.toBeInTheDocument());
+      expect(screen.getByText('Photo 1 of 3')).toBeInTheDocument();
+      expect(screen.getByDisplayValue('Draft not saved yet')).toBeInTheDocument();
+      expect(onClose).not.toHaveBeenCalled();
+    });
+
+    it('closes the details modal on Escape, and only the lightbox while that is open', async () => {
+      const user = userEvent.setup();
+      render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+      await screen.findByDisplayValue('Print with brim');
+      await user.click(screen.getAllByAltText('Photos')[1]);
+      await screen.findByText('Photo 2 of 3');
+
+      await user.keyboard('{Escape}');
+      await waitFor(() => expect(screen.queryByText('Photo 2 of 3')).not.toBeInTheDocument());
+      expect(onClose).not.toHaveBeenCalled();
+
+      await user.keyboard('{Escape}');
+      await waitFor(() => expect(onClose).toHaveBeenCalled());
+    });
+
+    it('keeps the lightbox open when the delete confirmation is dismissed with Escape', async () => {
+      // The confirmation has its own Escape handler. With the gallery's still
+      // listening, one press cancelled the prompt and closed the gallery under
+      // it, losing the user's place in the photo list.
+      const user = userEvent.setup();
+      const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+      await screen.findByDisplayValue('Print with brim');
+      await user.click(screen.getAllByAltText('Photos')[1]);
+      await screen.findByText('Photo 2 of 3');
+      await user.click(container.querySelector('.text-red-400') as HTMLElement);
+      await screen.findByText('Delete Photo');
+
+      await user.keyboard('{Escape}');
+
+      await waitFor(() => expect(screen.queryByText('Delete Photo')).not.toBeInTheDocument());
+      expect(screen.getByText('Photo 2 of 3')).toBeInTheDocument();
+      expect(onClose).not.toHaveBeenCalled();
+    });
+
+    describe('after the last photo is deleted from inside the lightbox', () => {
+      beforeEach(() => {
+        server.use(
+          http.get('/api/v1/library/files/7', () => HttpResponse.json({ ...details, photos: ['only.jpg'] }))
+        );
+      });
+
+      const deleteTheOnlyPhotoFromTheLightbox = async (
+        user: ReturnType<typeof userEvent.setup>,
+        container: HTMLElement
+      ) => {
+        await screen.findByDisplayValue('Print with brim');
+        await user.click(screen.getByAltText('Photos'));
+        await screen.findByText('Photo 1 of 1');
+        await user.click(container.querySelector('.text-red-400') as HTMLElement);
+        await user.click(await screen.findByRole('button', { name: /^delete$/i }));
+        await waitFor(() => expect(screen.queryByText('Photo 1 of 1')).not.toBeInTheDocument());
+      };
+
+      it('leaves Escape closing the details modal', async () => {
+        // Emptying the list unmounts the gallery before its own "nothing left
+        // to show" branch can call onClose, so the details modal never learned
+        // the lightbox had gone and kept its Escape handler stood down.
+        const user = userEvent.setup();
+        const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+        await deleteTheOnlyPhotoFromTheLightbox(user, container);
+
+        await user.keyboard('{Escape}');
+        await waitFor(() => expect(onClose).toHaveBeenCalled());
+      });
+
+      it('does not re-open the lightbox when the next photo is uploaded', async () => {
+        server.use(
+          http.post('/api/v1/library/files/7/photos', () =>
+            HttpResponse.json({ status: 'uploaded', photos: ['replacement.jpg'] })
+          )
+        );
+        const user = userEvent.setup();
+        const { container } = render(<LibraryFileDetailsModal file={listItem} canEdit onClose={onClose} />);
+
+        await deleteTheOnlyPhotoFromTheLightbox(user, container);
+
+        await user.upload(
+          screen.getByLabelText('Add photo'),
+          new File(['jpeg'], 'replacement.jpg', { type: 'image/jpeg' })
+        );
+
+        const photo = (await screen.findByAltText('Photos')) as HTMLImageElement;
+        expect(photo.src).toContain('/library/files/7/photos/replacement.jpg');
+        expect(screen.queryByText('Photo 1 of 1')).not.toBeInTheDocument();
+      });
+    });
+  });
+});

+ 101 - 0
frontend/src/__tests__/components/PdfPreviewModal.test.tsx

@@ -0,0 +1,101 @@
+/**
+ * Tests for PdfPreviewModal (#2976).
+ *
+ * pdf.js cannot rasterise inside jsdom (no real canvas), so the library is
+ * mocked at the module boundary; the tests cover the modal's own logic —
+ * loading, page navigation, and error/size fallbacks.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { PdfPreviewModal } from '../../components/PdfPreviewModal';
+
+const pdfjsMocks = vi.hoisted(() => {
+  const render = vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() }));
+  const getPage = vi.fn(async () => ({
+    getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
+    render,
+  }));
+  const getDocument = vi.fn(() => ({
+    promise: Promise.resolve({ numPages: 3, getPage }),
+    destroy: vi.fn(),
+  }));
+  return { render, getPage, getDocument };
+});
+
+vi.mock('pdfjs-dist', () => ({
+  GlobalWorkerOptions: { workerSrc: '' },
+  getDocument: pdfjsMocks.getDocument,
+}));
+
+vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: 'pdf.worker.min.mjs' }));
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
+  },
+  getAuthToken: () => null,
+}));
+
+const mockOnClose = vi.fn();
+
+function renderModal(props: Partial<Parameters<typeof PdfPreviewModal>[0]> = {}) {
+  return render(
+    <PdfPreviewModal
+      libraryFileId={7}
+      filename="drawing.pdf"
+      fileSize={1024}
+      onClose={mockOnClose}
+      {...props}
+    />,
+  );
+}
+
+describe('PdfPreviewModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
+    );
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('shows the page indicator once the document loads', async () => {
+    renderModal();
+    expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
+    expect(pdfjsMocks.render).toHaveBeenCalled();
+  });
+
+  it('navigates between pages', async () => {
+    const user = userEvent.setup();
+    renderModal();
+    await screen.findByText('Page 1 of 3');
+
+    await user.click(screen.getByRole('button', { name: 'Next page' }));
+    expect(await screen.findByText('Page 2 of 3')).toBeInTheDocument();
+    expect(pdfjsMocks.getPage).toHaveBeenLastCalledWith(2);
+
+    await user.click(screen.getByRole('button', { name: 'Previous page' }));
+    expect(await screen.findByText('Page 1 of 3')).toBeInTheDocument();
+  });
+
+  it('shows an error message when the document cannot be parsed', async () => {
+    pdfjsMocks.getDocument.mockReturnValueOnce({ promise: Promise.reject(new Error('bad pdf')), destroy: vi.fn() } as never);
+    renderModal();
+    expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
+  });
+
+  it('refuses oversized files without fetching them', async () => {
+    const fetchSpy = vi.fn();
+    vi.stubGlobal('fetch', fetchSpy);
+    renderModal({ fileSize: 500 * 1024 * 1024 });
+
+    expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
+    expect(fetchSpy).not.toHaveBeenCalled();
+  });
+});

+ 114 - 0
frontend/src/__tests__/components/SpreadsheetPreviewModal.test.tsx

@@ -0,0 +1,114 @@
+/**
+ * Tests for SpreadsheetPreviewModal (#2976).
+ *
+ * CSV parsing uses the real papaparse and XLSX parsing the real SheetJS —
+ * only the network fetch is stubbed, so the tests cover the actual parse
+ * paths the preview relies on.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import * as XLSX from 'xlsx';
+import { SpreadsheetPreviewModal } from '../../components/SpreadsheetPreviewModal';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getLibraryFileDownloadUrl: vi.fn((id: number) => `http://test/library/files/${id}/download`),
+  },
+  getAuthToken: () => null,
+}));
+
+const mockOnClose = vi.fn();
+
+function stubFetchWith(bytes: ArrayBuffer | Uint8Array | string) {
+  const body = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes;
+  vi.stubGlobal(
+    'fetch',
+    vi.fn(async () => new Response(body as BodyInit, { status: 200 })),
+  );
+}
+
+function renderModal(props: Partial<Parameters<typeof SpreadsheetPreviewModal>[0]> = {}) {
+  return render(
+    <SpreadsheetPreviewModal
+      libraryFileId={42}
+      filename="parts.csv"
+      fileType="csv"
+      fileSize={1234}
+      onClose={mockOnClose}
+      {...props}
+    />,
+  );
+}
+
+describe('SpreadsheetPreviewModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it('renders CSV cells as a read-only grid', async () => {
+    stubFetchWith('Article,Qty\nM3 screw,12\nBearing 608,4\n');
+    renderModal();
+
+    expect(await screen.findByText('M3 screw')).toBeInTheDocument();
+    expect(screen.getByText('Bearing 608')).toBeInTheDocument();
+    expect(screen.getByText('Qty')).toBeInTheDocument();
+  });
+
+  it('shows a truncation notice for long CSV files', async () => {
+    const rows = Array.from({ length: 600 }, (_, i) => `row${i},${i}`).join('\n');
+    stubFetchWith(`name,value\n${rows}\n`);
+    renderModal();
+
+    expect(await screen.findByText('row0')).toBeInTheDocument();
+    expect(screen.getByText(/Showing the first/)).toBeInTheDocument();
+    expect(screen.queryByText('row599')).not.toBeInTheDocument();
+  });
+
+  it('renders XLSX workbooks with one tab per sheet', async () => {
+    const workbook = XLSX.utils.book_new();
+    XLSX.utils.book_append_sheet(
+      workbook,
+      XLSX.utils.aoa_to_sheet([
+        ['Part', 'Price'],
+        ['Nozzle', '12.50'],
+      ]),
+      'Parts',
+    );
+    XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([['SupplierList']]), 'Suppliers');
+    const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer;
+    stubFetchWith(bytes);
+
+    const user = userEvent.setup();
+    renderModal({ filename: 'bom.xlsx', fileType: 'xlsx' });
+
+    expect(await screen.findByText('Nozzle')).toBeInTheDocument();
+    // Both sheets appear as tabs; switching shows the second sheet's content.
+    await user.click(screen.getByRole('button', { name: 'Suppliers' }));
+    expect(await screen.findByText('SupplierList')).toBeInTheDocument();
+    expect(screen.queryByText('Nozzle')).not.toBeInTheDocument();
+  });
+
+  it('shows an error message for a broken workbook', async () => {
+    // A truncated ZIP: SheetJS recognises the PK magic, then fails to parse.
+    // (Plain text bytes would be leniently read as CSV, not rejected.)
+    stubFetchWith(new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x01, 0x02, 0x03]));
+    renderModal({ filename: 'broken.xlsx', fileType: 'xlsx' });
+
+    expect(await screen.findByText('This file cannot be previewed.')).toBeInTheDocument();
+  });
+
+  it('refuses oversized files without fetching them', async () => {
+    const fetchSpy = vi.fn();
+    vi.stubGlobal('fetch', fetchSpy);
+    renderModal({ fileSize: 100 * 1024 * 1024 });
+
+    expect(await screen.findByText(/too large to preview/)).toBeInTheDocument();
+    expect(fetchSpy).not.toHaveBeenCalled();
+  });
+});

+ 183 - 0
frontend/src/__tests__/pages/FileManagerFileDetails.test.tsx

@@ -0,0 +1,183 @@
+/**
+ * File details entry points and indicators on the File Manager (#3077).
+ *
+ * The card kebab and the list row both offer "File details"; a card or row
+ * shows a globe (external link, opens in a new tab), a sticky-note icon
+ * (has notes) and a photo count only when the listing says so.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+vi.mock('../../components/LibraryFileDetailsModal', () => ({
+  LibraryFileDetailsModal: ({ file }: { file: { filename: string } }) => (
+    <div data-testid="details-modal">{file.filename}</div>
+  ),
+}));
+
+const base = {
+  file_path: '/library/x',
+  file_size: 1048576,
+  folder_id: null,
+  thumbnail_path: null,
+  print_name: null,
+  print_time_seconds: null,
+  print_count: 0,
+  duplicate_count: 0,
+  created_at: '2024-01-01T00:00:00Z',
+};
+
+const files = [
+  {
+    ...base,
+    id: 1,
+    filename: 'documented.stl',
+    file_type: 'stl',
+    external_url: 'https://www.printables.com/model/1',
+    has_notes: true,
+    photo_count: 3,
+  },
+  { ...base, id: 2, filename: 'bare.stl', file_type: 'stl', external_url: null, has_notes: false, photo_count: 0 },
+];
+
+function serve() {
+  server.use(
+    http.get('/api/v1/library/folders', () => HttpResponse.json([])),
+    http.get('/api/v1/library/files', () => HttpResponse.json(files)),
+    http.get('/api/v1/library/stats', () =>
+      HttpResponse.json({
+        total_files: files.length,
+        total_folders: 0,
+        total_size_bytes: 1,
+        disk_free_bytes: 1,
+        disk_total_bytes: 2,
+      }),
+    ),
+  );
+}
+
+function cardFor(filename: string): HTMLElement {
+  return screen.getByText(filename).closest('.group') as HTMLElement;
+}
+
+function rowFor(filename: string): HTMLElement {
+  return screen.getByText(filename).closest('div[class*="grid-cols-"]') as HTMLElement;
+}
+
+describe('FileManagerPage — file details (#3077)', () => {
+  beforeEach(() => {
+    serve();
+  });
+
+  afterEach(() => {
+    (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
+  });
+
+  describe('grid view', () => {
+    it('opens the details modal from the card menu', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bare.stl')).toBeInTheDocument());
+      const card = cardFor('bare.stl');
+      const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
+      await user.click(kebab);
+      await user.click(within(card).getByText('File details'));
+
+      expect(await screen.findByTestId('details-modal')).toHaveTextContent('bare.stl');
+    });
+
+    it('shows the link, notes and photo indicators only when the listing carries them', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
+
+      const documented = cardFor('documented.stl');
+      const globe = within(documented).getByLabelText('Open link');
+      expect(globe).toHaveAttribute('href', 'https://www.printables.com/model/1');
+      expect(globe).toHaveAttribute('target', '_blank');
+      expect(within(documented).getByLabelText('Has notes')).toBeInTheDocument();
+      expect(within(documented).getByLabelText('3 photos')).toHaveTextContent('3');
+
+      const bare = cardFor('bare.stl');
+      expect(within(bare).queryByLabelText('Open link')).not.toBeInTheDocument();
+      expect(within(bare).queryByLabelText('Has notes')).not.toBeInTheDocument();
+      expect(within(bare).queryByLabelText(/photos?$/)).not.toBeInTheDocument();
+    });
+
+    it('offers the link in the card menu only when the file has one', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
+
+      const documented = cardFor('documented.stl');
+      await user.click(documented.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement);
+      expect(within(documented).getByText('Open link')).toBeInTheDocument();
+      await user.keyboard('{Escape}');
+
+      const bare = cardFor('bare.stl');
+      await user.click(bare.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement);
+      expect(within(bare).getByText('File details')).toBeInTheDocument();
+      expect(within(bare).queryByText('Open link')).not.toBeInTheDocument();
+    });
+
+    it('opens the stored link without handing the new tab a window.opener', async () => {
+      // The URL comes from whoever owns the file, so the page it opens must
+      // not get a handle back on Bambuddy's window.
+      const open = vi.spyOn(window, 'open').mockReturnValue(null);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
+
+      const documented = cardFor('documented.stl');
+      await user.click(documented.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement);
+      await user.click(within(documented).getByText('Open link'));
+
+      expect(open).toHaveBeenCalledWith('https://www.printables.com/model/1', '_blank', 'noopener,noreferrer');
+      open.mockRestore();
+    });
+  });
+
+  describe('list view', () => {
+    beforeEach(() => {
+      (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
+        key === 'library-view-mode' ? 'list' : null,
+      );
+    });
+
+    it('opens the details modal from the row action', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bare.stl')).toBeInTheDocument());
+      await user.click(within(rowFor('bare.stl')).getByTitle('File details'));
+
+      expect(await screen.findByTestId('details-modal')).toHaveTextContent('bare.stl');
+    });
+
+    it('shows the indicators next to the name', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('documented.stl')).toBeInTheDocument());
+
+      const documented = rowFor('documented.stl');
+      expect(within(documented).getByLabelText('Open link')).toHaveAttribute(
+        'href',
+        'https://www.printables.com/model/1',
+      );
+      expect(within(documented).getByLabelText('Has notes')).toBeInTheDocument();
+      expect(within(documented).getByTitle('3 photos')).toHaveTextContent('3');
+
+      const bare = rowFor('bare.stl');
+      expect(within(bare).queryByLabelText('Open link')).not.toBeInTheDocument();
+      expect(within(bare).queryByLabelText('Has notes')).not.toBeInTheDocument();
+    });
+  });
+});

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

@@ -7479,8 +7479,54 @@ export const api = {
     window.URL.revokeObjectURL(url);
     window.URL.revokeObjectURL(url);
   },
   },
   getLibraryFileThumbnailUrl: (id: number) => withMediaToken(`${API_BASE}/library/files/${id}/thumbnail`),
   getLibraryFileThumbnailUrl: (id: number) => withMediaToken(`${API_BASE}/library/files/${id}/thumbnail`),
+  // Client-rendered preview thumbnail upload (#2976). STEP/PDF/spreadsheet
+  // previews render in the browser; the first render is posted back so the
+  // grid gets a thumbnail without a server-side renderer for those formats.
+  uploadLibraryPreviewThumbnail: async (fileId: number, thumbnail: Blob): Promise<{ updated: boolean }> => {
+    const formData = new FormData();
+    formData.append('thumbnail', thumbnail, 'preview.png');
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/library/files/${fileId}/preview-thumbnail`, {
+      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();
+  },
   getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
   getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
     withMediaToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),
     withMediaToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),
+  // Photos of the printed result (#3077) — same shape as the archive photo API.
+  getLibraryFilePhotoUrl: (fileId: number, filename: string) =>
+    withMediaToken(`${API_BASE}/library/files/${fileId}/photos/${encodeURIComponent(filename)}`),
+  uploadLibraryFilePhoto: async (fileId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
+    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}/library/files/${fileId}/photos`, {
+      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();
+  },
+  deleteLibraryFilePhoto: (fileId: number, filename: string) =>
+    request<{ status: string; photos: string[] }>(`/library/files/${fileId}/photos/${encodeURIComponent(filename)}`, {
+      method: 'DELETE',
+    }),
   getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
   getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
   moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
   moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
     request<{ status: string; moved: number }>('/library/files/move', {
     request<{ status: string; moved: number }>('/library/files/move', {
@@ -8046,6 +8092,11 @@ export interface LibraryFile {
   print_count: number;
   print_count: number;
   last_printed_at: string | null;
   last_printed_at: string | null;
   notes: string | null;
   notes: string | null;
+  // User link + photos of the printed result (#3077); source_url is the
+  // read-only import provenance (MakerWorld).
+  external_url: string | null;
+  photos: string[];
+  source_url: string | null;
   duplicates: LibraryFileDuplicate[] | null;
   duplicates: LibraryFileDuplicate[] | null;
   duplicate_count: number;
   duplicate_count: number;
   // User tracking (Issue #206)
   // User tracking (Issue #206)
@@ -8096,6 +8147,11 @@ export interface LibraryFileListItem {
   // matching rows on screen. 0 when the file is not grouped.
   // matching rows on screen. 0 when the file is not grouped.
   variant_group_id?: number | null;
   variant_group_id?: number | null;
   variant_count?: number;
   variant_count?: number;
+  // Metadata indicators (#3077). Optional for the same reason as `tags`: older
+  // mocks construct list items without them. Read sites default to falsy.
+  external_url?: string | null;
+  has_notes?: boolean;
+  photo_count?: number;
 }
 }
 
 
 // Variant groups (#671 / #2570): the same job sliced for different printers.
 // Variant groups (#671 / #2570): the same job sliced for different printers.
@@ -8133,6 +8189,7 @@ export interface LibraryFileUpdate {
   folder_id?: number | null;
   folder_id?: number | null;
   project_id?: number | null;
   project_id?: number | null;
   notes?: string | null;
   notes?: string | null;
+  external_url?: string | null;
 }
 }
 
 
 // Library trash (#1008)
 // Library trash (#1008)

+ 346 - 0
frontend/src/components/LibraryFileDetailsModal.tsx

@@ -0,0 +1,346 @@
+import { useEffect, useRef, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { X, Save, Link, Camera, Trash2, Loader2, Plus, ExternalLink, StickyNote } from 'lucide-react';
+import { api } from '../api/client';
+import type { LibraryFileListItem } from '../api/client';
+import { Button } from './Button';
+import { PhotoGalleryModal } from './PhotoGalleryModal';
+import { useToast } from '../contexts/ToastContext';
+import { formatDate, formatDuration } from '../utils/date';
+import { formatFileSize } from '../utils/file';
+
+interface LibraryFileDetailsModalProps {
+  file: LibraryFileListItem;
+  // Notes, link and photos are edits to the file, so they follow the same
+  // ownership gate as rename (`canModify('library', 'update', ...)`).
+  canEdit: boolean;
+  onClose: () => void;
+}
+
+// Notes, an external link and photos of the printed result on a library
+// file (#3077) — the same trio EditArchiveModal offers for an archive. Photos
+// are saved as they are added; notes and the link on Save.
+export function LibraryFileDetailsModal({ file, canEdit, onClose }: LibraryFileDetailsModalProps) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+
+  const { data: details } = useQuery({
+    queryKey: ['library-file', file.id],
+    queryFn: () => api.getLibraryFile(file.id),
+  });
+
+  const [notes, setNotes] = useState('');
+  const [externalUrl, setExternalUrl] = useState('');
+  const [photos, setPhotos] = useState<string[]>([]);
+  const [uploadingPhoto, setUploadingPhoto] = useState(false);
+  // The photo the lightbox opens on, or null while it is closed.
+  const [galleryIndex, setGalleryIndex] = useState<number | null>(null);
+  const photoInputRef = useRef<HTMLInputElement>(null);
+
+  // Seed the form once per file. Later refetches (photo changes below, window
+  // focus) must not overwrite notes or a link the user is still typing.
+  const seededForId = useRef<number | null>(null);
+  useEffect(() => {
+    if (!details || seededForId.current === details.id) return;
+    seededForId.current = details.id;
+    setNotes(details.notes ?? '');
+    setExternalUrl(details.external_url ?? '');
+    setPhotos(details.photos ?? []);
+  }, [details]);
+
+  // Escape closes the modal, as it does in the rest of the app. Not while the
+  // lightbox is open on top of it — that handles Escape itself, and a second
+  // listener here would close both at once.
+  useEffect(() => {
+    if (galleryIndex !== null) return;
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [galleryIndex, onClose]);
+
+  const invalidate = () => {
+    queryClient.invalidateQueries({ queryKey: ['library-files'] });
+    queryClient.invalidateQueries({ queryKey: ['library-file', file.id] });
+  };
+
+  const saveMutation = useMutation({
+    mutationFn: () => api.updateLibraryFile(file.id, { notes, external_url: externalUrl.trim() }),
+    onSuccess: () => {
+      invalidate();
+      showToast(t('fileManager.details.saved'), 'success');
+      onClose();
+    },
+    onError: () => {
+      showToast(t('fileManager.details.saveFailed'), 'error');
+    },
+  });
+
+  const hasChanges =
+    !!details && (notes !== (details.notes ?? '') || externalUrl.trim() !== (details.external_url ?? ''));
+
+  const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const picked = e.target.files?.[0];
+    if (!picked) return;
+    setUploadingPhoto(true);
+    try {
+      const result = await api.uploadLibraryFilePhoto(file.id, picked);
+      setPhotos(result.photos);
+      invalidate();
+    } catch {
+      showToast(t('fileManager.details.uploadFailed'), 'error');
+    } finally {
+      setUploadingPhoto(false);
+      if (photoInputRef.current) {
+        photoInputRef.current.value = '';
+      }
+    }
+  };
+
+  const handlePhotoDelete = async (filename: string) => {
+    try {
+      const result = await api.deleteLibraryFilePhoto(file.id, filename);
+      const remaining = result.photos ?? [];
+      setPhotos(remaining);
+      // Deleting the last photo unmounts the lightbox through the render
+      // guard below before its own "nothing left to show" branch can call
+      // onClose, so the index has to be cleared here. Left set, it keeps
+      // Escape disabled for good and re-opens the lightbox unasked as soon
+      // as another photo is uploaded.
+      if (remaining.length === 0) setGalleryIndex(null);
+      invalidate();
+    } catch {
+      showToast(t('fileManager.details.deleteFailed'), 'error');
+    }
+  };
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!canEdit || !hasChanges) return;
+    saveMutation.mutate();
+  };
+
+  const trimmedUrl = externalUrl.trim();
+  const facts: Array<{ label: string; value: React.ReactNode }> = [
+    { label: t('fileManager.details.size'), value: formatFileSize(file.file_size) },
+    { label: t('fileManager.details.type'), value: file.file_type.toUpperCase() },
+  ];
+  if (details?.print_name) facts.push({ label: t('fileManager.details.printName'), value: details.print_name });
+  if (details?.print_time_seconds) {
+    facts.push({ label: t('fileManager.details.printTime'), value: formatDuration(details.print_time_seconds) });
+  }
+  if (details?.filament_used_grams) {
+    facts.push({ label: t('fileManager.details.filament'), value: `${details.filament_used_grams.toFixed(1)} g` });
+  }
+  if (details?.sliced_for_model) facts.push({ label: t('fileManager.details.slicedFor'), value: details.sliced_for_model });
+  if (details?.source_url) {
+    facts.push({
+      label: t('fileManager.details.source'),
+      value: (
+        <a
+          href={details.source_url}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="text-bambu-green hover:underline inline-flex items-center gap-1 min-w-0"
+        >
+          <span className="truncate">{details.source_url}</span>
+          <ExternalLink className="w-3 h-3 flex-shrink-0" />
+        </a>
+      ),
+    });
+  }
+  facts.push({ label: t('fileManager.details.created'), value: formatDate(file.created_at) });
+  facts.push({
+    label: t('fileManager.details.modified'),
+    value: formatDate(file.fs_modified_at ?? details?.updated_at ?? file.created_at),
+  });
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between gap-3 px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="min-w-0">
+            <p className="text-xs text-bambu-gray">{t('fileManager.details.title')}</p>
+            <div className="flex items-center gap-2 min-w-0">
+              <h2 className="text-lg font-semibold text-white truncate" title={file.filename}>
+                {file.filename}
+              </h2>
+              <span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-bambu-dark-tertiary text-bambu-gray">
+                {file.file_type.toUpperCase()}
+              </span>
+            </div>
+          </div>
+          <button
+            type="button"
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors flex-shrink-0"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
+          {/* Facts */}
+          <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
+            {facts.map((fact) => (
+              <div key={fact.label} className="contents">
+                <dt className="text-bambu-gray whitespace-nowrap">{fact.label}</dt>
+                <dd className="text-white min-w-0 truncate">{fact.value}</dd>
+              </div>
+            ))}
+          </dl>
+
+          {/* Notes */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <StickyNote className="w-4 h-4 inline mr-1" />
+              {t('fileManager.details.notes')}
+            </label>
+            <textarea
+              value={notes}
+              onChange={(e) => setNotes(e.target.value)}
+              rows={3}
+              disabled={!canEdit}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none resize-none disabled:opacity-60"
+              placeholder={t('fileManager.details.notesPlaceholder')}
+            />
+          </div>
+
+          {/* External link */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <Link className="w-4 h-4 inline mr-1" />
+              {t('fileManager.details.externalLink')}
+            </label>
+            <div className="flex items-center gap-2">
+              <input
+                type="url"
+                value={externalUrl}
+                onChange={(e) => setExternalUrl(e.target.value)}
+                disabled={!canEdit}
+                className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-60"
+                placeholder={t('fileManager.details.externalLinkPlaceholder')}
+              />
+              {trimmedUrl && (
+                <a
+                  href={trimmedUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="p-2 rounded-lg text-bambu-gray hover:text-bambu-green hover:bg-bambu-dark transition-colors"
+                  title={t('fileManager.details.openLink')}
+                  aria-label={t('fileManager.details.openLink')}
+                >
+                  <ExternalLink className="w-4 h-4" />
+                </a>
+              )}
+            </div>
+          </div>
+
+          {/* Photos */}
+          <div>
+            <label className="block text-sm text-bambu-gray mb-1">
+              <Camera className="w-4 h-4 inline mr-1" />
+              {t('fileManager.details.photos')}
+            </label>
+            <div className="flex flex-wrap gap-2">
+              {photos.map((filename, index) => (
+                <div key={filename} className="relative group">
+                  <button
+                    type="button"
+                    onClick={() => setGalleryIndex(index)}
+                    className="block w-20 h-20 rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
+                  >
+                    <img
+                      src={api.getLibraryFilePhotoUrl(file.id, filename)}
+                      alt={t('fileManager.details.photos')}
+                      className="w-full h-full object-cover"
+                    />
+                  </button>
+                  {canEdit && (
+                    <button
+                      type="button"
+                      onClick={() => handlePhotoDelete(filename)}
+                      className="absolute -top-1 -right-1 p-1 bg-red-500 rounded-full can-hover:opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
+                      title={t('fileManager.details.deletePhoto')}
+                      aria-label={t('fileManager.details.deletePhoto')}
+                    >
+                      <Trash2 className="w-3 h-3 text-white" />
+                    </button>
+                  )}
+                </div>
+              ))}
+              {canEdit && (
+                <label
+                  className="w-20 h-20 flex items-center justify-center border-2 border-dashed border-bambu-dark-tertiary rounded-lg cursor-pointer hover:border-bambu-green transition-colors"
+                  title={t('fileManager.details.addPhoto')}
+                >
+                  <input
+                    ref={photoInputRef}
+                    type="file"
+                    accept="image/jpeg,image/png,image/webp"
+                    onChange={handlePhotoUpload}
+                    className="hidden"
+                    disabled={uploadingPhoto}
+                    aria-label={t('fileManager.details.addPhoto')}
+                  />
+                  {uploadingPhoto ? (
+                    <Loader2 className="w-6 h-6 text-bambu-gray animate-spin" />
+                  ) : (
+                    <Plus className="w-6 h-6 text-bambu-gray" />
+                  )}
+                </label>
+              )}
+              {photos.length === 0 && !canEdit && (
+                <p className="text-xs text-bambu-gray">{t('fileManager.details.noPhotos')}</p>
+              )}
+            </div>
+          </div>
+
+          {/* Actions */}
+          <div className="flex gap-3 pt-2">
+            <Button type="button" variant="secondary" onClick={onClose} className="flex-1">
+              {t('common.close')}
+            </Button>
+            {canEdit && (
+              <Button
+                type="submit"
+                variant="primary"
+                className="flex-1"
+                disabled={!hasChanges || saveMutation.isPending}
+              >
+                {saveMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <Save className="w-4 h-4" />
+                )}
+                {t('fileManager.details.save')}
+              </Button>
+            )}
+          </div>
+        </form>
+      </div>
+
+      {galleryIndex !== null && photos.length > 0 && (
+        <PhotoGalleryModal
+          archiveName={file.filename}
+          photos={photos}
+          initialIndex={galleryIndex}
+          getPhotoUrl={(filename) => api.getLibraryFilePhotoUrl(file.id, filename)}
+          onClose={() => setGalleryIndex(null)}
+          onDelete={canEdit ? handlePhotoDelete : undefined}
+        />
+      )}
+    </div>
+  );
+}

+ 174 - 12
frontend/src/components/ModelViewer.tsx

@@ -9,6 +9,60 @@ import JSZip from 'jszip';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Loader2, RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
 import { Button } from './Button';
 import { Button } from './Button';
 import { getAuthToken } from '../api/client';
 import { getAuthToken } from '../api/client';
+import type { StepWorkerMesh, StepWorkerResponse } from '../workers/stepPreview.worker';
+
+// STEP triangulation runs in a dedicated worker (#2976): OpenCascade-as-WASM
+// takes seconds on a real assembly and its embind glue needs an eval-relaxed
+// CSP that only the worker's own script response carries (see the worker
+// file and security_headers_middleware in backend/app/main.py). The worker —
+// and with it the ~7 MB wasm — loads on the first STEP preview and is then
+// kept warm for the page's lifetime.
+let stepWorker: Worker | null = null;
+let stepRequestId = 0;
+const stepPendingRequests = new Map<
+  number,
+  { resolve: (meshes: StepWorkerMesh[]) => void; reject: (err: Error) => void }
+>();
+
+function failAllStepRequests(message: string): void {
+  for (const pending of stepPendingRequests.values()) {
+    pending.reject(new Error(message));
+  }
+  stepPendingRequests.clear();
+}
+
+function getStepWorker(): Worker {
+  if (!stepWorker) {
+    const worker = new Worker(new URL('../workers/stepPreview.worker.ts', import.meta.url), { type: 'module' });
+    worker.onmessage = (event: MessageEvent<StepWorkerResponse>) => {
+      const pending = stepPendingRequests.get(event.data.id);
+      if (!pending) return;
+      stepPendingRequests.delete(event.data.id);
+      if (event.data.ok) {
+        pending.resolve(event.data.meshes);
+      } else {
+        pending.reject(new Error(event.data.reason));
+      }
+    };
+    // A crashed or unloadable worker is dropped so the next preview attempt
+    // starts a fresh one instead of reusing a dead instance.
+    worker.onerror = () => {
+      failAllStepRequests('error');
+      worker.terminate();
+      if (stepWorker === worker) stepWorker = null;
+    };
+    stepWorker = worker;
+  }
+  return stepWorker;
+}
+
+function parseStepInWorker(buffer: ArrayBuffer): Promise<StepWorkerMesh[]> {
+  return new Promise((resolve, reject) => {
+    const id = ++stepRequestId;
+    stepPendingRequests.set(id, { resolve, reject });
+    getStepWorker().postMessage({ id, buffer }, [buffer]);
+  });
+}
 
 
 /**
 /**
  * Frame the camera on a bounding box.
  * Frame the camera on a bounding box.
@@ -61,8 +115,15 @@ interface ModelViewerProps {
   filamentColors?: string[];
   filamentColors?: string[];
   selectedPlateId?: number | null;
   selectedPlateId?: number | null;
   className?: string;
   className?: string;
+  /** Called once with a 256px PNG of the first render — used by the file
+   * manager to persist a thumbnail for formats the server cannot render
+   * itself (STEP, #2976). */
+  onSnapshot?: (blob: Blob) => void;
 }
 }
 
 
+// Triangulated STEP shape as posted back by the step preview worker.
+type StepMeshData = StepWorkerMesh;
+
 interface MeshData {
 interface MeshData {
   vertices: number[];
   vertices: number[];
   triangles: number[];
   triangles: number[];
@@ -666,6 +727,41 @@ function buildModelGroup(
   return group;
   return group;
 }
 }
 
 
+// One mesh per STEP shape so per-part colours survive; parts without a colour
+// fall back to the same filament colour the STL path uses.
+function buildStepGroup(meshes: StepMeshData[], filamentColors?: string[]): THREE.Group {
+  const group = new THREE.Group();
+  const fallback = filamentColors?.[0] || '#00ae42';
+  for (const meshData of meshes) {
+    const geometry = new THREE.BufferGeometry();
+    geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3));
+    if (meshData.normals) {
+      geometry.setAttribute('normal', new THREE.BufferAttribute(meshData.normals, 3));
+    }
+    if (meshData.indices) {
+      geometry.setIndex(new THREE.BufferAttribute(meshData.indices, 1));
+    }
+    if (!meshData.normals) {
+      geometry.computeVertexNormals();
+    }
+    // STEP is Z-up like STL; three.js is Y-up.
+    geometry.rotateX(-Math.PI / 2);
+    const color = meshData.color
+      ? new THREE.Color(meshData.color[0], meshData.color[1], meshData.color[2])
+      : new THREE.Color(fallback);
+    const material = new THREE.MeshStandardMaterial({
+      color,
+      roughness: 0.62,
+      metalness: 0.0,
+      envMapIntensity: 0.55,
+    });
+    const mesh = new THREE.Mesh(geometry, material);
+    mesh.castShadow = true;
+    group.add(mesh);
+  }
+  return group;
+}
+
 export function ModelViewer({
 export function ModelViewer({
   url,
   url,
   fileType,
   fileType,
@@ -673,6 +769,7 @@ export function ModelViewer({
   filamentColors,
   filamentColors,
   selectedPlateId = null,
   selectedPlateId = null,
   className = '',
   className = '',
+  onSnapshot,
 }: ModelViewerProps) {
 }: ModelViewerProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const containerRef = useRef<HTMLDivElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
@@ -693,6 +790,14 @@ export function ModelViewer({
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
   const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
   const [parsedData, setParsedData] = useState<Parsed3MFData | null>(null);
   const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
   const [stlGeometry, setStlGeometry] = useState<THREE.BufferGeometry | null>(null);
+  const [stepMeshes, setStepMeshes] = useState<StepMeshData[] | null>(null);
+  // Snapshot is a one-shot per loaded url; the callback lives in a ref so its
+  // identity never retriggers the (expensive) scene effects.
+  const snapshotSentRef = useRef(false);
+  const onSnapshotRef = useRef(onSnapshot);
+  useEffect(() => {
+    onSnapshotRef.current = onSnapshot;
+  });
 
 
   useEffect(() => {
   useEffect(() => {
     if (!containerRef.current) return;
     if (!containerRef.current) return;
@@ -819,6 +924,8 @@ export function ModelViewer({
     setError(null);
     setError(null);
     setParsedData(null);
     setParsedData(null);
     setStlGeometry(null);
     setStlGeometry(null);
+    setStepMeshes(null);
+    snapshotSentRef.current = false;
 
 
     const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
     const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
 
 
@@ -863,6 +970,27 @@ export function ModelViewer({
           setError(err.message);
           setError(err.message);
           setLoading(false);
           setLoading(false);
         });
         });
+    } else if (normalizedType === 'step' || normalizedType === 'stp') {
+      // STEP has no three.js loader; triangulation happens in the dedicated
+      // OpenCascade worker (#2976), off the UI thread.
+      fetch(url, { headers })
+        .then((res) => {
+          if (!res.ok) throw new Error(t('modelViewer.errors.failedToLoad'));
+          return res.arrayBuffer();
+        })
+        .then(parseStepInWorker)
+        .then(setStepMeshes)
+        .catch((err: Error) => {
+          // Worker rejections carry machine reasons; everything else already
+          // is a translated message from the fetch step above.
+          const message = err.message === 'no-meshes'
+            ? t('modelViewer.errors.noMeshes')
+            : err.message === 'error'
+              ? t('modelViewer.errors.failedToLoad')
+              : err.message;
+          setError(message);
+          setLoading(false);
+        });
     } else {
     } else {
       setError(t('modelViewer.errors.unsupportedFormat'));
       setError(t('modelViewer.errors.unsupportedFormat'));
       setLoading(false);
       setLoading(false);
@@ -909,15 +1037,17 @@ export function ModelViewer({
 
 
   useEffect(() => {
   useEffect(() => {
     if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
     if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
-    if (!parsedData && !stlGeometry) return;
+    if (!parsedData && !stlGeometry && !stepMeshes) return;
 
 
     if (modelGroupRef.current) {
     if (modelGroupRef.current) {
       sceneRef.current.remove(modelGroupRef.current);
       sceneRef.current.remove(modelGroupRef.current);
       disposeGroup(modelGroupRef.current);
       disposeGroup(modelGroupRef.current);
     }
     }
 
 
-    const isStlModel = !!stlGeometry;
-    const group = isStlModel
+    // STL and STEP are plain single models with no plate/build-item layout;
+    // they share the same centre-on-plate placement below.
+    const isPlainModel = !!stlGeometry || !!stepMeshes;
+    const group = stlGeometry
       ? (() => {
       ? (() => {
           const materialColor = filamentColors?.[0] || '#00ae42';
           const materialColor = filamentColors?.[0] || '#00ae42';
           const material = new THREE.MeshStandardMaterial({
           const material = new THREE.MeshStandardMaterial({
@@ -926,13 +1056,15 @@ export function ModelViewer({
             metalness: 0.0,
             metalness: 0.0,
             envMapIntensity: 0.55,
             envMapIntensity: 0.55,
           });
           });
-          const mesh = new THREE.Mesh(stlGeometry!, material);
+          const mesh = new THREE.Mesh(stlGeometry, material);
           mesh.castShadow = true;
           mesh.castShadow = true;
           const stlGroup = new THREE.Group();
           const stlGroup = new THREE.Group();
           stlGroup.add(mesh);
           stlGroup.add(mesh);
           return stlGroup;
           return stlGroup;
         })()
         })()
-      : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
+      : stepMeshes
+        ? buildStepGroup(stepMeshes, filamentColors)
+        : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
     modelGroupRef.current = group;
     modelGroupRef.current = group;
     sceneRef.current.add(group);
     sceneRef.current.add(group);
 
 
@@ -943,13 +1075,13 @@ export function ModelViewer({
     // Always place models on the build plate (Y=0)
     // Always place models on the build plate (Y=0)
     group.position.y = -box.min.y;
     group.position.y = -box.min.y;
 
 
-    const selectedPlateBounds = (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
+    const selectedPlateBounds = (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0)
       ? parsedData!.plateBounds.get(selectedPlateId)
       ? parsedData!.plateBounds.get(selectedPlateId)
       : undefined;
       : undefined;
-    const selectedPlateOffset = (!isStlModel && selectedPlateId != null)
+    const selectedPlateOffset = (!isPlainModel && selectedPlateId != null)
       ? parsedData!.plateOffsets.get(selectedPlateId)
       ? parsedData!.plateOffsets.get(selectedPlateId)
       : undefined;
       : undefined;
-    const shouldCenterOnPlate = isStlModel
+    const shouldCenterOnPlate = isPlainModel
       || parsedData!.buildItems.length === 0
       || parsedData!.buildItems.length === 0
       || (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);
       || (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);
     const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;
     const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;
@@ -957,7 +1089,7 @@ export function ModelViewer({
 
 
     let plateOffsetX = 0;
     let plateOffsetX = 0;
     let plateOffsetZ = 0;
     let plateOffsetZ = 0;
-    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
+    if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
       const plateBox = new THREE.Box3().setFromObject(group);
       const plateBox = new THREE.Box3().setFromObject(group);
       plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;
       plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;
       plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;
       plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;
@@ -966,10 +1098,10 @@ export function ModelViewer({
     const plateCenterX = buildVolume.x / 2;
     const plateCenterX = buildVolume.x / 2;
     const plateCenterZ = buildVolume.y / 2;
     const plateCenterZ = buildVolume.y / 2;
 
 
-    if (!isStlModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
+    if (!isPlainModel && selectedPlateId != null && parsedData!.buildItems.length > 0 && selectedPlateBounds) {
       group.position.x = centerOffsetX - plateOffsetX;
       group.position.x = centerOffsetX - plateOffsetX;
       group.position.z = centerOffsetZ - plateOffsetZ;
       group.position.z = centerOffsetZ - plateOffsetZ;
-    } else if (!isStlModel && selectedPlateId != null && selectedPlateOffset) {
+    } else if (!isPlainModel && selectedPlateId != null && selectedPlateOffset) {
       group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);
       group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);
       group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);
       group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);
     } else if (shouldCenterOnPlate) {
     } else if (shouldCenterOnPlate) {
@@ -1003,7 +1135,37 @@ export function ModelViewer({
     fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
     fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
 
 
     setLoading(false);
     setLoading(false);
-  }, [parsedData, stlGeometry, selectedPlateId, filamentColors, buildVolume]);
+
+    // One-shot snapshot of the first framed render (#2976). Rendering
+    // explicitly right before reading the canvas keeps the WebGL buffer
+    // valid without preserveDrawingBuffer.
+    if (onSnapshotRef.current && !snapshotSentRef.current) {
+      snapshotSentRef.current = true;
+      requestAnimationFrame(() => {
+        const renderer = rendererRef.current;
+        const scene = sceneRef.current;
+        const camera = cameraRef.current;
+        if (!renderer || !scene || !camera) return;
+        try {
+          renderer.render(scene, camera);
+          const source = renderer.domElement;
+          const size = 256;
+          const side = Math.min(source.width, source.height);
+          const target = document.createElement('canvas');
+          target.width = size;
+          target.height = size;
+          const ctx = target.getContext('2d');
+          if (!ctx) return;
+          ctx.drawImage(source, (source.width - side) / 2, (source.height - side) / 2, side, side, 0, 0, size, size);
+          target.toBlob((blob) => {
+            if (blob) onSnapshotRef.current?.(blob);
+          }, 'image/png');
+        } catch {
+          // Snapshot is best-effort; the preview itself already rendered.
+        }
+      });
+    }
+  }, [parsedData, stlGeometry, stepMeshes, selectedPlateId, filamentColors, buildVolume]);
 
 
   const resetView = () => {
   const resetView = () => {
     if (cameraRef.current && controlsRef.current) {
     if (cameraRef.current && controlsRef.current) {

+ 7 - 2
frontend/src/components/ModelViewerModal.tsx

@@ -23,6 +23,9 @@ interface ModelViewerModalProps {
   // externally — so the preview modal's slice action matches the file row's
   // externally — so the preview modal's slice action matches the file row's
   // Cog (in-app Bambuddy SliceModal) when the slicer API is enabled.
   // Cog (in-app Bambuddy SliceModal) when the slicer API is enabled.
   onSliceWithBambuddy?: () => void;
   onSliceWithBambuddy?: () => void;
+  // Forwarded to ModelViewer: one 256px PNG of the first render, used by the
+  // file manager to persist a STEP thumbnail (#2976).
+  onSnapshot?: (blob: Blob) => void;
 }
 }
 
 
 interface Capabilities {
 interface Capabilities {
@@ -136,7 +139,7 @@ function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: Sli
   );
   );
 }
 }
 
 
-export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
+export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy, onSnapshot }: ModelViewerModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
   const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
@@ -185,7 +188,8 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
       // upload path tags it `3mf`, so we accept both shapes here for
       // upload path tags it `3mf`, so we accept both shapes here for
       // the 3D-tab + g-code-tab gating (#1543).
       // the 3D-tab + g-code-tab gating (#1543).
       const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
       const isThreeMfFamily = normalizedType === '3mf' || normalizedType === 'gcode.3mf';
-      const hasModel = isThreeMfFamily || normalizedType === 'stl';
+      // STEP joins the 3D tab via the OpenCascade WASM loader in ModelViewer (#2976).
+      const hasModel = isThreeMfFamily || normalizedType === 'stl' || normalizedType === 'step' || normalizedType === 'stp';
       setCapabilities({
       setCapabilities({
         has_model: hasModel,
         has_model: hasModel,
         has_source: false,
         has_source: false,
@@ -772,6 +776,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                     filamentColors={capabilities.filament_colors}
                     filamentColors={capabilities.filament_colors}
                     selectedPlateId={selectedPlateId}
                     selectedPlateId={selectedPlateId}
                     className="w-full h-full"
                     className="w-full h-full"
+                    onSnapshot={onSnapshot}
                   />
                   />
               </div>
               </div>
             </div>
             </div>

+ 244 - 0
frontend/src/components/PdfPreviewModal.tsx

@@ -0,0 +1,244 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ChevronLeft, ChevronRight, FileText, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react';
+import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
+import { api, getAuthToken } from '../api/client';
+import { formatFileSize } from '../utils/file';
+
+// Fetching and parsing happen fully in the browser; beyond this size the
+// preview shows a notice instead of stalling the tab on a giant download.
+export const PDF_PREVIEW_MAX_BYTES = 50 * 1024 * 1024;
+
+interface PdfPreviewModalProps {
+  libraryFileId: number;
+  filename: string;
+  fileSize: number;
+  onClose: () => void;
+  /** Called once with a 256px PNG of the first page, for the grid thumbnail (#2976). */
+  onSnapshot?: (blob: Blob) => void;
+}
+
+// Square 256px crop of the rendered page, white-backed like a paper page.
+function snapshotFromCanvas(source: HTMLCanvasElement): Promise<Blob | null> {
+  const size = 256;
+  const target = document.createElement('canvas');
+  target.width = size;
+  target.height = size;
+  const ctx = target.getContext('2d');
+  if (!ctx) return Promise.resolve(null);
+  ctx.fillStyle = '#ffffff';
+  ctx.fillRect(0, 0, size, size);
+  const scale = size / Math.max(source.width, source.height);
+  const w = source.width * scale;
+  const h = source.height * scale;
+  ctx.drawImage(source, (size - w) / 2, (size - h) / 2, w, h);
+  return new Promise((resolve) => target.toBlob(resolve, 'image/png'));
+}
+
+export function PdfPreviewModal({ libraryFileId, filename, fileSize, onClose, onSnapshot }: PdfPreviewModalProps) {
+  const { t } = useTranslation();
+  const containerRef = useRef<HTMLDivElement>(null);
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const renderTaskRef = useRef<RenderTask | null>(null);
+  const snapshotSentRef = useRef(false);
+  const onSnapshotRef = useRef(onSnapshot);
+  useEffect(() => {
+    onSnapshotRef.current = onSnapshot;
+  });
+
+  const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
+  const [pageNum, setPageNum] = useState(1);
+  const [zoom, setZoom] = useState(1);
+  const [error, setError] = useState<string | null>(null);
+  const [rendering, setRendering] = useState(true);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Load the document. pdf.js is imported on demand so the viewer and its
+  // worker stay out of the main bundle.
+  useEffect(() => {
+    let cancelled = false;
+    let loadingTask: PDFDocumentLoadingTask | null = null;
+    setDoc(null);
+    setError(null);
+    setPageNum(1);
+    setRendering(true);
+
+    if (fileSize > PDF_PREVIEW_MAX_BYTES) {
+      setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
+      setRendering(false);
+      return;
+    }
+
+    const headers: HeadersInit = {};
+    const token = getAuthToken();
+    if (token) headers['Authorization'] = `Bearer ${token}`;
+
+    (async () => {
+      const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      const buffer = await res.arrayBuffer();
+      const pdfjs = await import('pdfjs-dist');
+      if (!pdfjs.GlobalWorkerOptions.workerSrc) {
+        pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
+      }
+      loadingTask = pdfjs.getDocument({ data: new Uint8Array(buffer) });
+      const loaded = await loadingTask.promise;
+      if (cancelled) {
+        // Cleanup below already ran; destroying the task tears down the doc.
+        return;
+      }
+      setDoc(loaded);
+    })().catch(() => {
+      if (!cancelled) {
+        setError(t('fileManager.preview.error'));
+        setRendering(false);
+      }
+    });
+
+    return () => {
+      cancelled = true;
+      renderTaskRef.current?.cancel();
+      renderTaskRef.current = null;
+      // Destroying the loading task also destroys the document and worker.
+      loadingTask?.destroy();
+    };
+  }, [libraryFileId, fileSize, t]);
+
+  // Render the current page into the canvas.
+  useEffect(() => {
+    if (!doc) return;
+    let cancelled = false;
+    setRendering(true);
+
+    (async () => {
+      const page = await doc.getPage(pageNum);
+      const canvas = canvasRef.current;
+      const container = containerRef.current;
+      if (!canvas || !container || cancelled) return;
+
+      const baseViewport = page.getViewport({ scale: 1 });
+      // Fit the page width to the panel at zoom 1; render at device pixels.
+      const fitScale = Math.max((container.clientWidth - 32) / baseViewport.width, 0.1);
+      const dpr = Math.min(window.devicePixelRatio || 1, 2);
+      const viewport = page.getViewport({ scale: fitScale * zoom * dpr });
+      canvas.width = viewport.width;
+      canvas.height = viewport.height;
+      canvas.style.width = `${viewport.width / dpr}px`;
+      canvas.style.height = `${viewport.height / dpr}px`;
+
+      renderTaskRef.current?.cancel();
+      const task = page.render({ canvas, viewport });
+      renderTaskRef.current = task;
+      await task.promise;
+      if (cancelled) return;
+      setRendering(false);
+
+      if (pageNum === 1 && onSnapshotRef.current && !snapshotSentRef.current) {
+        snapshotSentRef.current = true;
+        const blob = await snapshotFromCanvas(canvas);
+        if (blob && !cancelled) onSnapshotRef.current?.(blob);
+      }
+    })().catch((err: unknown) => {
+      // A cancelled render throws RenderingCancelledException — not an error.
+      if (!cancelled && (err as { name?: string })?.name !== 'RenderingCancelledException') {
+        setError(t('fileManager.preview.error'));
+        setRendering(false);
+      }
+    });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [doc, pageNum, zoom, t]);
+
+  const pageCount = doc?.numPages ?? 0;
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-5xl h-[85vh] border border-bambu-dark-tertiary 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-2 min-w-0">
+            <FileText className="w-5 h-5 text-bambu-green flex-shrink-0" />
+            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
+          </div>
+          <div className="flex items-center gap-2 flex-shrink-0">
+            {doc && pageCount > 1 && (
+              <div className="flex items-center gap-1 mr-2">
+                <button
+                  onClick={() => setPageNum((p) => Math.max(1, p - 1))}
+                  disabled={pageNum <= 1}
+                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
+                  aria-label={t('fileManager.preview.prevPage')}
+                >
+                  <ChevronLeft className="w-4 h-4" />
+                </button>
+                <span className="text-sm text-bambu-gray whitespace-nowrap">
+                  {t('fileManager.preview.page', { current: pageNum, total: pageCount })}
+                </span>
+                <button
+                  onClick={() => setPageNum((p) => Math.min(pageCount, p + 1))}
+                  disabled={pageNum >= pageCount}
+                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
+                  aria-label={t('fileManager.preview.nextPage')}
+                >
+                  <ChevronRight className="w-4 h-4" />
+                </button>
+              </div>
+            )}
+            {doc && (
+              <>
+                <button
+                  onClick={() => setZoom((z) => Math.max(0.4, z * 0.8))}
+                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                  aria-label={t('fileManager.preview.zoomOut')}
+                >
+                  <ZoomOut className="w-4 h-4" />
+                </button>
+                <button
+                  onClick={() => setZoom((z) => Math.min(4, z * 1.25))}
+                  className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                  aria-label={t('fileManager.preview.zoomIn')}
+                >
+                  <ZoomIn className="w-4 h-4" />
+                </button>
+              </>
+            )}
+            <button
+              onClick={onClose}
+              className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+              aria-label={t('common.close')}
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+        </div>
+
+        {/* Page */}
+        <div ref={containerRef} className="relative flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg p-4">
+          {error ? (
+            <div className="h-full flex items-center justify-center">
+              <p className="text-bambu-gray text-center">{error}</p>
+            </div>
+          ) : (
+            <div className="flex justify-center min-w-fit">
+              <canvas ref={canvasRef} className="shadow-lg" />
+            </div>
+          )}
+          {!error && rendering && (
+            <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 55 - 28
frontend/src/components/PhotoGalleryModal.tsx

@@ -4,26 +4,37 @@ import { api } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
 
 
-interface PhotoGalleryModalProps {
-  archiveId: number;
+// Archive photos resolve through `archiveId`; any other owner (a library
+// file, #3077) passes `getPhotoUrl` instead. Exactly one of the two is
+// required so a caller cannot silently end up requesting archive 0.
+type PhotoSource =
+  | { archiveId: number; getPhotoUrl?: undefined }
+  | { archiveId?: undefined; getPhotoUrl: (filename: string) => string };
+
+type PhotoGalleryModalProps = PhotoSource & {
   archiveName: string;
   archiveName: string;
   photos: string[];
   photos: string[];
+  // Which photo to open on. A caller that opens the gallery from a per-photo
+  // grid passes the clicked index (#3077); one with a single "view photos"
+  // button leaves it at the first.
+  initialIndex?: number;
   onClose: () => void;
   onClose: () => void;
   onDelete?: (filename: string) => void;
   onDelete?: (filename: string) => void;
-}
+};
 
 
-export function PhotoGalleryModal({
-  archiveId,
-  archiveName,
-  photos,
-  onClose,
-  onDelete,
-}: PhotoGalleryModalProps) {
-  const [currentIndex, setCurrentIndex] = useState(0);
+export function PhotoGalleryModal(props: PhotoGalleryModalProps) {
+  const { archiveName, photos, initialIndex, onClose, onDelete } = props;
+  const [currentIndex, setCurrentIndex] = useState(() =>
+    Math.min(Math.max(initialIndex ?? 0, 0), Math.max(photos.length - 1, 0))
+  );
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
 
 
-  // Keyboard navigation
+  // Keyboard navigation. Stands down while the delete confirmation is up:
+  // that has its own Escape handler, so one press would cancel the prompt and
+  // close the gallery underneath it, and arrow keys would move the selection
+  // out from under a confirmation already naming a photo.
   useEffect(() => {
   useEffect(() => {
+    if (showDeleteConfirm) return;
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
       if (e.key === 'Escape') onClose();
       if (e.key === 'Escape') onClose();
       if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
       if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
@@ -31,7 +42,7 @@ export function PhotoGalleryModal({
     };
     };
     window.addEventListener('keydown', handleKeyDown);
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose, photos.length]);
+  }, [onClose, photos.length, showDeleteConfirm]);
 
 
   // Reset index if photos change
   // Reset index if photos change
   useEffect(() => {
   useEffect(() => {
@@ -45,8 +56,11 @@ export function PhotoGalleryModal({
     return null;
     return null;
   }
   }
 
 
+  const resolvePhotoUrl = (filename: string) =>
+    props.getPhotoUrl ? props.getPhotoUrl(filename) : api.getArchivePhotoUrl(props.archiveId, filename);
+
   const currentPhoto = photos[currentIndex];
   const currentPhoto = photos[currentIndex];
-  const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
+  const photoUrl = resolvePhotoUrl(currentPhoto);
 
 
   const handleDownload = () => {
   const handleDownload = () => {
     const link = document.createElement('a');
     const link = document.createElement('a');
@@ -61,10 +75,18 @@ export function PhotoGalleryModal({
     }
     }
   };
   };
 
 
+  // The gallery can be rendered inside another modal's overlay, and that
+  // overlay closes on a backdrop click (#3077). Dismissing the gallery must
+  // not bubble up and take the parent — and its unsaved edits — with it.
+  const handleBackdropClick = (e: React.MouseEvent) => {
+    e.stopPropagation();
+    onClose();
+  };
+
   return (
   return (
     <div
     <div
       className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
       className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
-      onClick={onClose}
+      onClick={handleBackdropClick}
     >
     >
       <div
       <div
         className="relative w-full h-full flex flex-col"
         className="relative w-full h-full flex flex-col"
@@ -142,7 +164,7 @@ export function PhotoGalleryModal({
                 }`}
                 }`}
               >
               >
                 <img
                 <img
-                  src={api.getArchivePhotoUrl(archiveId, photo)}
+                  src={resolvePhotoUrl(photo)}
                   alt={`Thumbnail ${index + 1}`}
                   alt={`Thumbnail ${index + 1}`}
                   className="w-full h-full object-cover"
                   className="w-full h-full object-cover"
                 />
                 />
@@ -152,19 +174,24 @@ export function PhotoGalleryModal({
         )}
         )}
       </div>
       </div>
 
 
-      {/* Delete Confirmation Modal */}
+      {/* Delete Confirmation Modal. Wrapped so that cancelling it by clicking
+          its backdrop cancels only the confirmation — the click would
+          otherwise reach the gallery backdrop below it and close the gallery
+          as well. */}
       {showDeleteConfirm && (
       {showDeleteConfirm && (
-        <ConfirmModal
-          title="Delete Photo"
-          message="Delete this photo? This cannot be undone."
-          confirmText="Delete"
-          variant="danger"
-          onConfirm={() => {
-            onDelete?.(currentPhoto);
-            setShowDeleteConfirm(false);
-          }}
-          onCancel={() => setShowDeleteConfirm(false)}
-        />
+        <div onClick={(e) => e.stopPropagation()}>
+          <ConfirmModal
+            title="Delete Photo"
+            message="Delete this photo? This cannot be undone."
+            confirmText="Delete"
+            variant="danger"
+            onConfirm={() => {
+              onDelete?.(currentPhoto);
+              setShowDeleteConfirm(false);
+            }}
+            onCancel={() => setShowDeleteConfirm(false)}
+          />
+        </div>
       )}
       )}
     </div>
     </div>
   );
   );

+ 305 - 0
frontend/src/components/SpreadsheetPreviewModal.tsx

@@ -0,0 +1,305 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { FileSpreadsheet, Loader2, X } from 'lucide-react';
+import { api, getAuthToken } from '../api/client';
+import { formatFileSize } from '../utils/file';
+
+// Parsing an arbitrarily large workbook would freeze the tab — anything over
+// this size (or beyond the row/column caps) falls back to a truncation notice.
+export const SPREADSHEET_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
+const MAX_ROWS = 500;
+const MAX_COLS = 40;
+
+interface SheetGrid {
+  name: string;
+  rows: string[][];
+  totalRows: number;
+  totalCols: number;
+}
+
+interface SpreadsheetPreviewModalProps {
+  libraryFileId: number;
+  filename: string;
+  /** csv | xlsx | ods */
+  fileType: string;
+  fileSize: number;
+  onClose: () => void;
+  /** Called once with a 256px PNG of the first sheet, for the grid thumbnail (#2976). */
+  onSnapshot?: (blob: Blob) => void;
+}
+
+// Spreadsheet-style column letters: 0 -> A, 25 -> Z, 26 -> AA, ...
+function columnLabel(index: number): string {
+  let label = '';
+  let i = index;
+  while (i >= 0) {
+    label = String.fromCharCode(65 + (i % 26)) + label;
+    i = Math.floor(i / 26) - 1;
+  }
+  return label;
+}
+
+// Mini table rendered onto a canvas as the grid thumbnail. Dark background to
+// match the STL thumbnails the grid already shows.
+function drawSheetSnapshot(rows: string[][]): Promise<Blob | null> {
+  const size = 256;
+  const cols = 5;
+  const rowCount = 9;
+  const canvas = document.createElement('canvas');
+  canvas.width = size;
+  canvas.height = size;
+  const ctx = canvas.getContext('2d');
+  if (!ctx) return Promise.resolve(null);
+
+  const cellW = size / cols;
+  const cellH = size / rowCount;
+  ctx.fillStyle = '#1a1a1a';
+  ctx.fillRect(0, 0, size, size);
+  ctx.fillStyle = 'rgba(0, 174, 66, 0.25)';
+  ctx.fillRect(0, 0, size, cellH);
+  ctx.strokeStyle = '#333333';
+  ctx.lineWidth = 1;
+  for (let c = 1; c < cols; c++) {
+    ctx.beginPath();
+    ctx.moveTo(c * cellW + 0.5, 0);
+    ctx.lineTo(c * cellW + 0.5, size);
+    ctx.stroke();
+  }
+  for (let r = 1; r < rowCount; r++) {
+    ctx.beginPath();
+    ctx.moveTo(0, r * cellH + 0.5);
+    ctx.lineTo(size, r * cellH + 0.5);
+    ctx.stroke();
+  }
+  ctx.fillStyle = '#d4d4d4';
+  ctx.font = '11px sans-serif';
+  ctx.textBaseline = 'middle';
+  for (let r = 0; r < rowCount; r++) {
+    for (let c = 0; c < cols; c++) {
+      const text = rows[r]?.[c];
+      if (!text) continue;
+      ctx.fillText(String(text), c * cellW + 4, r * cellH + cellH / 2, cellW - 8);
+    }
+  }
+  return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
+}
+
+export function SpreadsheetPreviewModal({
+  libraryFileId,
+  filename,
+  fileType,
+  fileSize,
+  onClose,
+  onSnapshot,
+}: SpreadsheetPreviewModalProps) {
+  const { t } = useTranslation();
+  const [sheets, setSheets] = useState<SheetGrid[] | null>(null);
+  const [activeSheet, setActiveSheet] = useState(0);
+  const [error, setError] = useState<string | null>(null);
+  const snapshotSentRef = useRef(false);
+  const onSnapshotRef = useRef(onSnapshot);
+  useEffect(() => {
+    onSnapshotRef.current = onSnapshot;
+  });
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  useEffect(() => {
+    let cancelled = false;
+    setSheets(null);
+    setError(null);
+    setActiveSheet(0);
+
+    if (fileSize > SPREADSHEET_PREVIEW_MAX_BYTES) {
+      setError(t('fileManager.preview.tooLarge', { size: formatFileSize(fileSize) }));
+      return;
+    }
+
+    const headers: HeadersInit = {};
+    const token = getAuthToken();
+    if (token) headers['Authorization'] = `Bearer ${token}`;
+
+    (async () => {
+      const res = await fetch(api.getLibraryFileDownloadUrl(libraryFileId), { headers });
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      const buffer = await res.arrayBuffer();
+
+      let parsed: SheetGrid[];
+      if (fileType === 'csv') {
+        // papaparse is loaded on demand so it stays out of the main bundle.
+        const Papa = (await import('papaparse')).default;
+        const text = new TextDecoder().decode(buffer);
+        const result = Papa.parse<string[]>(text, { skipEmptyLines: false });
+        const all = result.data.filter((row) => Array.isArray(row));
+        // A trailing newline parses as one empty row — drop trailing blanks.
+        while (all.length > 0 && all[all.length - 1].every((cell) => !cell)) {
+          all.pop();
+        }
+        const totalRows = all.length;
+        const totalCols = all.reduce((max, row) => Math.max(max, row.length), 0);
+        const rows = all.slice(0, MAX_ROWS).map((row) => row.slice(0, MAX_COLS).map((cell) => cell ?? ''));
+        parsed = [{ name: filename, rows, totalRows, totalCols }];
+      } else {
+        // SheetJS handles both XLSX and ODS; loaded on demand like papaparse.
+        const XLSX = await import('xlsx');
+        const workbook = XLSX.read(buffer, { dense: true });
+        parsed = workbook.SheetNames.map((name) => {
+          const ws = workbook.Sheets[name];
+          const ref = ws?.['!ref'];
+          if (!ws || !ref) return { name, rows: [], totalRows: 0, totalCols: 0 };
+          const range = XLSX.utils.decode_range(ref);
+          const totalRows = range.e.r - range.s.r + 1;
+          const totalCols = range.e.c - range.s.c + 1;
+          // Cap the extracted range instead of slicing afterwards, so a huge
+          // sheet is never materialised in full.
+          const capped = {
+            s: range.s,
+            e: {
+              r: Math.min(range.e.r, range.s.r + MAX_ROWS - 1),
+              c: Math.min(range.e.c, range.s.c + MAX_COLS - 1),
+            },
+          };
+          const rows = XLSX.utils.sheet_to_json(ws, {
+            header: 1,
+            raw: false,
+            defval: '',
+            range: XLSX.utils.encode_range(capped),
+          }) as string[][];
+          return { name, rows, totalRows, totalCols };
+        });
+      }
+
+      if (cancelled) return;
+      setSheets(parsed);
+
+      if (onSnapshotRef.current && !snapshotSentRef.current) {
+        const first = parsed.find((sheet) => sheet.rows.length > 0);
+        if (first) {
+          snapshotSentRef.current = true;
+          const blob = await drawSheetSnapshot(first.rows);
+          if (blob && !cancelled) onSnapshotRef.current?.(blob);
+        }
+      }
+    })().catch(() => {
+      if (!cancelled) setError(t('fileManager.preview.error'));
+    });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [libraryFileId, fileType, fileSize, filename, t]);
+
+  const sheet = sheets?.[activeSheet] ?? null;
+  const rowsTruncated = sheet != null && sheet.totalRows > sheet.rows.length;
+  const colsTruncated = sheet != null && sheet.totalCols > MAX_COLS;
+  const shownCols = sheet == null ? 0 : Math.min(sheet.totalCols, MAX_COLS);
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
+      <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-6xl h-[85vh] border border-bambu-dark-tertiary 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-2 min-w-0">
+            <FileSpreadsheet className="w-5 h-5 text-bambu-green flex-shrink-0" />
+            <h2 className="text-lg font-semibold text-white truncate">{filename}</h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Sheet tabs */}
+        {sheets && sheets.length > 1 && (
+          <div className="flex gap-1 px-4 pt-2 overflow-x-auto flex-shrink-0">
+            {sheets.map((s, index) => (
+              <button
+                key={`${s.name}-${index}`}
+                onClick={() => setActiveSheet(index)}
+                className={`px-3 py-1.5 text-sm rounded-t whitespace-nowrap transition-colors ${
+                  index === activeSheet
+                    ? 'bg-bambu-dark text-white border border-b-0 border-bambu-dark-tertiary'
+                    : 'text-bambu-gray hover:text-white hover:bg-bambu-dark/50'
+                }`}
+              >
+                {s.name}
+              </button>
+            ))}
+          </div>
+        )}
+
+        {/* Content */}
+        <div className="flex-1 min-h-0 overflow-auto bg-bambu-dark rounded-b-lg">
+          {error ? (
+            <div className="h-full flex items-center justify-center p-6">
+              <p className="text-bambu-gray text-center">{error}</p>
+            </div>
+          ) : !sheets ? (
+            <div className="h-full flex items-center justify-center">
+              <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+            </div>
+          ) : !sheet || sheet.rows.length === 0 ? (
+            <div className="h-full flex items-center justify-center p-6">
+              <p className="text-bambu-gray">{t('fileManager.preview.emptySheet')}</p>
+            </div>
+          ) : (
+            <table className="border-collapse text-xs">
+              <thead>
+                <tr>
+                  <th className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium w-10" />
+                  {Array.from({ length: shownCols }, (_, c) => (
+                    <th
+                      key={c}
+                      className="sticky top-0 bg-bambu-dark-secondary border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray font-medium text-left min-w-[80px]"
+                    >
+                      {columnLabel(c)}
+                    </th>
+                  ))}
+                </tr>
+              </thead>
+              <tbody>
+                {sheet.rows.map((row, r) => (
+                  <tr key={r}>
+                    <td className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray text-right bg-bambu-dark-secondary/50">
+                      {r + 1}
+                    </td>
+                    {Array.from({ length: shownCols }, (_, c) => (
+                      <td
+                        key={c}
+                        className="border border-bambu-dark-tertiary px-2 py-1 text-bambu-gray-light whitespace-nowrap max-w-[280px] overflow-hidden text-ellipsis"
+                        title={row[c] || undefined}
+                      >
+                        {row[c] ?? ''}
+                      </td>
+                    ))}
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </div>
+
+        {/* Truncation notices */}
+        {(rowsTruncated || colsTruncated) && sheet && (
+          <div className="px-4 py-2 border-t border-bambu-dark-tertiary text-xs text-bambu-gray flex gap-4 flex-shrink-0">
+            {rowsTruncated && (
+              <span>{t('fileManager.preview.truncatedRows', { shown: sheet.rows.length, total: sheet.totalRows })}</span>
+            )}
+            {colsTruncated && (
+              <span>{t('fileManager.preview.truncatedCols', { shown: shownCols, total: sheet.totalCols })}</span>
+            )}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

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

@@ -3995,6 +3995,49 @@ export default {
     noPermissionDownload: 'Sie haben keine Berechtigung, Dateien herunterzuladen',
     noPermissionDownload: 'Sie haben keine Berechtigung, Dateien herunterzuladen',
     noPermissionPreview: 'Sie haben keine Berechtigung, Dateien in der Vorschau anzuzeigen',
     noPermissionPreview: 'Sie haben keine Berechtigung, Dateien in der Vorschau anzuzeigen',
     preview3d: '3D-Vorschau',
     preview3d: '3D-Vorschau',
+    // In-App-Dokumentvorschau (#2976): PDF- und Tabellendateien.
+    preview: {
+      open: 'Vorschau',
+      error: 'Für diese Datei ist keine Vorschau möglich.',
+      tooLarge: 'Die Datei ist zu groß für eine Vorschau ({{size}}).',
+      page: 'Seite {{current}} von {{total}}',
+      prevPage: 'Vorherige Seite',
+      nextPage: 'Nächste Seite',
+      zoomIn: 'Vergrößern',
+      zoomOut: 'Verkleinern',
+      emptySheet: 'Dieses Tabellenblatt ist leer',
+      truncatedRows: 'Die ersten {{shown}} von {{total}} Zeilen werden angezeigt',
+      truncatedCols: 'Die ersten {{shown}} von {{total}} Spalten werden angezeigt',
+    },
+    details: {
+      title: 'Dateidetails',
+      size: 'Größe',
+      type: 'Typ',
+      printName: 'Druckname',
+      printTime: 'Druckzeit',
+      filament: 'Filament',
+      slicedFor: 'Gesliced für',
+      source: 'Quelle',
+      created: 'Erstellt',
+      modified: 'Geändert',
+      notes: 'Notizen',
+      notesPlaceholder: 'Druckeinstellungen, Tipps, was funktioniert hat...',
+      externalLink: 'Externer Link',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Link öffnen',
+      photos: 'Fotos',
+      addPhoto: 'Foto hinzufügen',
+      noPhotos: 'Noch keine Fotos',
+      deletePhoto: 'Foto löschen',
+      photoCount_one: '{{count}} Foto',
+      photoCount_other: '{{count}} Fotos',
+      hasNotes: 'Hat Notizen',
+      save: 'Speichern',
+      saved: 'Dateidetails gespeichert',
+      saveFailed: 'Dateidetails konnten nicht gespeichert werden',
+      uploadFailed: 'Foto konnte nicht hochgeladen werden',
+      deleteFailed: 'Foto konnte nicht gelöscht werden',
+    },
     noPermissionRenameFile: 'Sie haben keine Berechtigung, diese Datei umzubenennen',
     noPermissionRenameFile: 'Sie haben keine Berechtigung, diese Datei umzubenennen',
     noPermissionGenerateThumbnail: 'Sie haben keine Berechtigung, Vorschaubilder zu generieren',
     noPermissionGenerateThumbnail: 'Sie haben keine Berechtigung, Vorschaubilder zu generieren',
     noPermissionDeleteFile: 'Sie haben keine Berechtigung, diese Datei zu löschen',
     noPermissionDeleteFile: 'Sie haben keine Berechtigung, diese Datei zu löschen',

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

@@ -4025,6 +4025,49 @@ export default {
     noPermissionDownload: 'You do not have permission to download files',
     noPermissionDownload: 'You do not have permission to download files',
     noPermissionPreview: 'You do not have permission to preview files',
     noPermissionPreview: 'You do not have permission to preview files',
     preview3d: '3D Preview',
     preview3d: '3D Preview',
+    // In-app document previews (#2976): PDF and spreadsheet files.
+    preview: {
+      open: 'Preview',
+      error: 'This file cannot be previewed.',
+      tooLarge: 'File is too large to preview ({{size}}).',
+      page: 'Page {{current}} of {{total}}',
+      prevPage: 'Previous page',
+      nextPage: 'Next page',
+      zoomIn: 'Zoom in',
+      zoomOut: 'Zoom out',
+      emptySheet: 'This sheet is empty',
+      truncatedRows: 'Showing the first {{shown}} of {{total}} rows',
+      truncatedCols: 'Showing the first {{shown}} of {{total}} columns',
+    },
+    details: {
+      title: 'File details',
+      size: 'Size',
+      type: 'Type',
+      printName: 'Print name',
+      printTime: 'Print time',
+      filament: 'Filament',
+      slicedFor: 'Sliced for',
+      source: 'Source',
+      created: 'Created',
+      modified: 'Modified',
+      notes: 'Notes',
+      notesPlaceholder: 'Print settings, tips, what worked...',
+      externalLink: 'External link',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Open link',
+      photos: 'Photos',
+      addPhoto: 'Add photo',
+      noPhotos: 'No photos yet',
+      deletePhoto: 'Delete photo',
+      photoCount_one: '{{count}} photo',
+      photoCount_other: '{{count}} photos',
+      hasNotes: 'Has notes',
+      save: 'Save',
+      saved: 'File details saved',
+      saveFailed: 'Failed to save file details',
+      uploadFailed: 'Failed to upload photo',
+      deleteFailed: 'Failed to delete photo',
+    },
     noPermissionRenameFile: 'You do not have permission to rename this file',
     noPermissionRenameFile: 'You do not have permission to rename this file',
     noPermissionGenerateThumbnail: 'You do not have permission to generate thumbnails',
     noPermissionGenerateThumbnail: 'You do not have permission to generate thumbnails',
     noPermissionDeleteFile: 'You do not have permission to delete this file',
     noPermissionDeleteFile: 'You do not have permission to delete this file',

+ 43 - 0
frontend/src/i18n/locales/es.ts

@@ -3997,6 +3997,49 @@ export default {
     noPermissionDownload: 'No tiene permiso para descargar archivos',
     noPermissionDownload: 'No tiene permiso para descargar archivos',
     noPermissionPreview: 'No tienes permiso para previsualizar archivos',
     noPermissionPreview: 'No tienes permiso para previsualizar archivos',
     preview3d: 'Vista previa 3D',
     preview3d: 'Vista previa 3D',
+    // Vistas previas de documentos en la aplicación (#2976): archivos PDF y hojas de cálculo.
+    preview: {
+      open: 'Vista previa',
+      error: 'Este archivo no se puede previsualizar.',
+      tooLarge: 'El archivo es demasiado grande para previsualizarlo ({{size}}).',
+      page: 'Página {{current}} de {{total}}',
+      prevPage: 'Página anterior',
+      nextPage: 'Página siguiente',
+      zoomIn: 'Acercar',
+      zoomOut: 'Alejar',
+      emptySheet: 'Esta hoja está vacía',
+      truncatedRows: 'Mostrando las primeras {{shown}} de {{total}} filas',
+      truncatedCols: 'Mostrando las primeras {{shown}} de {{total}} columnas',
+    },
+    details: {
+      title: 'Detalles del archivo',
+      size: 'Tamaño',
+      type: 'Tipo',
+      printName: 'Nombre de impresión',
+      printTime: 'Tiempo de impresión',
+      filament: 'Filamento',
+      slicedFor: 'Laminado para',
+      source: 'Origen',
+      created: 'Creado',
+      modified: 'Modificado',
+      notes: 'Notas',
+      notesPlaceholder: 'Ajustes de impresión, consejos, qué funcionó...',
+      externalLink: 'Enlace externo',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Abrir enlace',
+      photos: 'Fotos',
+      addPhoto: 'Añadir foto',
+      noPhotos: 'Aún no hay fotos',
+      deletePhoto: 'Eliminar foto',
+      photoCount_one: '{{count}} foto',
+      photoCount_other: '{{count}} fotos',
+      hasNotes: 'Tiene notas',
+      save: 'Guardar',
+      saved: 'Detalles del archivo guardados',
+      saveFailed: 'No se pudieron guardar los detalles del archivo',
+      uploadFailed: 'No se pudo subir la foto',
+      deleteFailed: 'No se pudo eliminar la foto',
+    },
     noPermissionRenameFile: 'No tiene permiso para renombrar este archivo',
     noPermissionRenameFile: 'No tiene permiso para renombrar este archivo',
     noPermissionGenerateThumbnail: 'No tiene permiso para generar miniaturas',
     noPermissionGenerateThumbnail: 'No tiene permiso para generar miniaturas',
     noPermissionDeleteFile: 'No tiene permiso para eliminar este archivo',
     noPermissionDeleteFile: 'No tiene permiso para eliminar este archivo',

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

@@ -3984,6 +3984,49 @@ export default {
     noPermissionDownload: 'Pas d\'autorisation téléchargement',
     noPermissionDownload: 'Pas d\'autorisation téléchargement',
     noPermissionPreview: 'Vous n\'avez pas la permission de prévisualiser les fichiers',
     noPermissionPreview: 'Vous n\'avez pas la permission de prévisualiser les fichiers',
     preview3d: 'Aperçu 3D',
     preview3d: 'Aperçu 3D',
+    // Aperçus de documents intégrés (#2976) : fichiers PDF et tableurs.
+    preview: {
+      open: 'Aperçu',
+      error: 'Ce fichier ne peut pas être prévisualisé.',
+      tooLarge: 'Le fichier est trop volumineux pour être prévisualisé ({{size}}).',
+      page: 'Page {{current}} sur {{total}}',
+      prevPage: 'Page précédente',
+      nextPage: 'Page suivante',
+      zoomIn: 'Zoom avant',
+      zoomOut: 'Zoom arrière',
+      emptySheet: 'Cette feuille est vide',
+      truncatedRows: 'Affichage des {{shown}} premières lignes sur {{total}}',
+      truncatedCols: 'Affichage des {{shown}} premières colonnes sur {{total}}',
+    },
+    details: {
+      title: 'Détails du fichier',
+      size: 'Taille',
+      type: 'Type',
+      printName: 'Nom d\'impression',
+      printTime: 'Temps d\'impression',
+      filament: 'Filament',
+      slicedFor: 'Tranché pour',
+      source: 'Source',
+      created: 'Créé',
+      modified: 'Modifié',
+      notes: 'Notes',
+      notesPlaceholder: 'Réglages d\'impression, astuces, ce qui a fonctionné...',
+      externalLink: 'Lien externe',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Ouvrir le lien',
+      photos: 'Photos',
+      addPhoto: 'Ajouter une photo',
+      noPhotos: 'Pas encore de photos',
+      deletePhoto: 'Supprimer la photo',
+      photoCount_one: '{{count}} photo',
+      photoCount_other: '{{count}} photos',
+      hasNotes: 'Contient des notes',
+      save: 'Enregistrer',
+      saved: 'Détails du fichier enregistrés',
+      saveFailed: 'Échec de l\'enregistrement des détails du fichier',
+      uploadFailed: 'Échec de l\'envoi de la photo',
+      deleteFailed: 'Échec de la suppression de la photo',
+    },
     noPermissionRenameFile: 'Pas d\'autorisation renommage fichier',
     noPermissionRenameFile: 'Pas d\'autorisation renommage fichier',
     noPermissionGenerateThumbnail: 'Pas d\'autorisation vignettes',
     noPermissionGenerateThumbnail: 'Pas d\'autorisation vignettes',
     noPermissionDeleteFile: 'Pas d\'autorisation suppression fichier',
     noPermissionDeleteFile: 'Pas d\'autorisation suppression fichier',

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

@@ -3983,6 +3983,49 @@ export default {
     noPermissionDownload: 'Non hai il permesso di scaricare file',
     noPermissionDownload: 'Non hai il permesso di scaricare file',
     noPermissionPreview: 'Non hai il permesso di visualizzare l\'anteprima dei file',
     noPermissionPreview: 'Non hai il permesso di visualizzare l\'anteprima dei file',
     preview3d: 'Anteprima 3D',
     preview3d: 'Anteprima 3D',
+    // Anteprime dei documenti in-app (#2976): file PDF e fogli di calcolo.
+    preview: {
+      open: 'Anteprima',
+      error: 'Impossibile visualizzare l\'anteprima di questo file.',
+      tooLarge: 'Il file è troppo grande per l\'anteprima ({{size}}).',
+      page: 'Pagina {{current}} di {{total}}',
+      prevPage: 'Pagina precedente',
+      nextPage: 'Pagina successiva',
+      zoomIn: 'Ingrandisci',
+      zoomOut: 'Riduci',
+      emptySheet: 'Questo foglio è vuoto',
+      truncatedRows: 'Visualizzate le prime {{shown}} righe di {{total}}',
+      truncatedCols: 'Visualizzate le prime {{shown}} colonne di {{total}}',
+    },
+    details: {
+      title: 'Dettagli del file',
+      size: 'Dimensione',
+      type: 'Tipo',
+      printName: 'Nome stampa',
+      printTime: 'Tempo di stampa',
+      filament: 'Filamento',
+      slicedFor: 'Slicing per',
+      source: 'Origine',
+      created: 'Creato',
+      modified: 'Modificato',
+      notes: 'Note',
+      notesPlaceholder: 'Impostazioni di stampa, consigli, cosa ha funzionato...',
+      externalLink: 'Link esterno',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Apri link',
+      photos: 'Foto',
+      addPhoto: 'Aggiungi foto',
+      noPhotos: 'Ancora nessuna foto',
+      deletePhoto: 'Elimina foto',
+      photoCount_one: '{{count}} foto',
+      photoCount_other: '{{count}} foto',
+      hasNotes: 'Contiene note',
+      save: 'Salva',
+      saved: 'Dettagli del file salvati',
+      saveFailed: 'Impossibile salvare i dettagli del file',
+      uploadFailed: 'Impossibile caricare la foto',
+      deleteFailed: 'Impossibile eliminare la foto',
+    },
     noPermissionRenameFile: 'Non hai il permesso di rinominare questo file',
     noPermissionRenameFile: 'Non hai il permesso di rinominare questo file',
     noPermissionGenerateThumbnail: 'Non hai il permesso di generare miniature',
     noPermissionGenerateThumbnail: 'Non hai il permesso di generare miniature',
     noPermissionDeleteFile: 'Non hai il permesso di eliminare questo file',
     noPermissionDeleteFile: 'Non hai il permesso di eliminare questo file',

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

@@ -3995,6 +3995,49 @@ export default {
     noPermissionDownload: 'ファイルをダウンロードする権限がありません',
     noPermissionDownload: 'ファイルをダウンロードする権限がありません',
     noPermissionPreview: 'ファイルをプレビューする権限がありません',
     noPermissionPreview: 'ファイルをプレビューする権限がありません',
     preview3d: '3Dプレビュー',
     preview3d: '3Dプレビュー',
+    // アプリ内ドキュメントプレビュー(#2976):PDFおよびスプレッドシートファイル。
+    preview: {
+      open: 'プレビュー',
+      error: 'このファイルはプレビューできません。',
+      tooLarge: 'ファイルが大きすぎるためプレビューできません({{size}})。',
+      page: '{{current}} / {{total}} ページ',
+      prevPage: '前のページ',
+      nextPage: '次のページ',
+      zoomIn: '拡大',
+      zoomOut: '縮小',
+      emptySheet: 'このシートは空です',
+      truncatedRows: '全{{total}}行のうち最初の{{shown}}行を表示しています',
+      truncatedCols: '全{{total}}列のうち最初の{{shown}}列を表示しています',
+    },
+    details: {
+      title: 'ファイルの詳細',
+      size: 'サイズ',
+      type: '種類',
+      printName: 'プリント名',
+      printTime: '印刷時間',
+      filament: 'フィラメント',
+      slicedFor: 'スライス対象',
+      source: 'ソース',
+      created: '作成日',
+      modified: '更新日',
+      notes: 'メモ',
+      notesPlaceholder: '印刷設定、コツ、うまくいった点など...',
+      externalLink: '外部リンク',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'リンクを開く',
+      photos: '写真',
+      addPhoto: '写真を追加',
+      noPhotos: 'まだ写真がありません',
+      deletePhoto: '写真を削除',
+      photoCount_one: '写真 {{count}} 枚',
+      photoCount_other: '写真 {{count}} 枚',
+      hasNotes: 'メモあり',
+      save: '保存',
+      saved: 'ファイルの詳細を保存しました',
+      saveFailed: 'ファイルの詳細を保存できませんでした',
+      uploadFailed: '写真をアップロードできませんでした',
+      deleteFailed: '写真を削除できませんでした',
+    },
     noPermissionRenameFile: 'このファイル名を変更する権限がありません',
     noPermissionRenameFile: 'このファイル名を変更する権限がありません',
     noPermissionGenerateThumbnail: 'サムネイルを生成する権限がありません',
     noPermissionGenerateThumbnail: 'サムネイルを生成する権限がありません',
     noPermissionDeleteFile: 'このファイルを削除する権限がありません',
     noPermissionDeleteFile: 'このファイルを削除する権限がありません',

+ 43 - 0
frontend/src/i18n/locales/ko.ts

@@ -3805,6 +3805,49 @@ export default {
     noPermissionDownload: '파일 다운로드 권한이 없습니다',
     noPermissionDownload: '파일 다운로드 권한이 없습니다',
     noPermissionPreview: '파일을 미리 볼 권한이 없습니다',
     noPermissionPreview: '파일을 미리 볼 권한이 없습니다',
     preview3d: '3D 미리보기',
     preview3d: '3D 미리보기',
+    // In-app document previews (#2976): PDF and spreadsheet files.
+    preview: {
+      open: '미리보기',
+      error: '이 파일은 미리 볼 수 없습니다.',
+      tooLarge: '파일이 너무 커서 미리 볼 수 없습니다 ({{size}}).',
+      page: '{{total}}페이지 중 {{current}}페이지',
+      prevPage: '이전 페이지',
+      nextPage: '다음 페이지',
+      zoomIn: '확대',
+      zoomOut: '축소',
+      emptySheet: '이 시트는 비어 있습니다',
+      truncatedRows: '전체 {{total}}개 행 중 처음 {{shown}}개 표시 중',
+      truncatedCols: '전체 {{total}}개 열 중 처음 {{shown}}개 표시 중',
+    },
+    details: {
+      title: '파일 세부 정보',
+      size: '크기',
+      type: '유형',
+      printName: '출력물 이름',
+      printTime: '출력 시간',
+      filament: '필라멘트',
+      slicedFor: '슬라이싱 대상',
+      source: '출처',
+      created: '생성일',
+      modified: '수정일',
+      notes: '메모',
+      notesPlaceholder: '출력 설정, 팁, 잘 된 점...',
+      externalLink: '외부 링크',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: '링크 열기',
+      photos: '사진',
+      addPhoto: '사진 추가',
+      noPhotos: '아직 사진이 없습니다',
+      deletePhoto: '사진 삭제',
+      photoCount_one: '사진 {{count}}장',
+      photoCount_other: '사진 {{count}}장',
+      hasNotes: '메모 있음',
+      save: '저장',
+      saved: '파일 세부 정보가 저장되었습니다',
+      saveFailed: '파일 세부 정보를 저장하지 못했습니다',
+      uploadFailed: '사진을 업로드하지 못했습니다',
+      deleteFailed: '사진을 삭제하지 못했습니다',
+    },
     noPermissionRenameFile: '파일 이름 변경 권한이 없습니다',
     noPermissionRenameFile: '파일 이름 변경 권한이 없습니다',
     noPermissionGenerateThumbnail: '썸네일 생성 권한이 없습니다',
     noPermissionGenerateThumbnail: '썸네일 생성 권한이 없습니다',
     noPermissionDeleteFile: '파일 삭제 권한이 없습니다',
     noPermissionDeleteFile: '파일 삭제 권한이 없습니다',

+ 43 - 0
frontend/src/i18n/locales/nl.ts

@@ -4025,6 +4025,49 @@ export default {
     noPermissionDownload: 'Je hebt geen toestemming om bestanden te downloaden',
     noPermissionDownload: 'Je hebt geen toestemming om bestanden te downloaden',
     noPermissionPreview: 'Je hebt geen toestemming om bestanden te bekijken',
     noPermissionPreview: 'Je hebt geen toestemming om bestanden te bekijken',
     preview3d: '3D-voorbeeld',
     preview3d: '3D-voorbeeld',
+    // In-app documentvoorbeelden (#2976): PDF- en spreadsheetbestanden.
+    preview: {
+      open: 'Voorbeeld',
+      error: 'Dit bestand kan niet worden weergegeven.',
+      tooLarge: 'Bestand is te groot om weer te geven ({{size}}).',
+      page: 'Pagina {{current}} van {{total}}',
+      prevPage: 'Vorige pagina',
+      nextPage: 'Volgende pagina',
+      zoomIn: 'Inzoomen',
+      zoomOut: 'Uitzoomen',
+      emptySheet: 'Dit werkblad is leeg',
+      truncatedRows: 'Eerste {{shown}} van {{total}} rijen worden getoond',
+      truncatedCols: 'Eerste {{shown}} van {{total}} kolommen worden getoond',
+    },
+    details: {
+      title: 'Bestandsdetails',
+      size: 'Grootte',
+      type: 'Type',
+      printName: 'Printnaam',
+      printTime: 'Printtijd',
+      filament: 'Filament',
+      slicedFor: 'Gesliced voor',
+      source: 'Bron',
+      created: 'Aangemaakt',
+      modified: 'Gewijzigd',
+      notes: 'Notities',
+      notesPlaceholder: 'Printinstellingen, tips, wat werkte...',
+      externalLink: 'Externe link',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Link openen',
+      photos: 'Foto\'s',
+      addPhoto: 'Foto toevoegen',
+      noPhotos: 'Nog geen foto\'s',
+      deletePhoto: 'Foto verwijderen',
+      photoCount_one: '{{count}} foto',
+      photoCount_other: '{{count}} foto\'s',
+      hasNotes: 'Heeft notities',
+      save: 'Opslaan',
+      saved: 'Bestandsdetails opgeslagen',
+      saveFailed: 'Bestandsdetails konden niet worden opgeslagen',
+      uploadFailed: 'Foto kon niet worden geüpload',
+      deleteFailed: 'Foto kon niet worden verwijderd',
+    },
     noPermissionRenameFile: 'Je hebt geen toestemming om dit bestand te hernoemen',
     noPermissionRenameFile: 'Je hebt geen toestemming om dit bestand te hernoemen',
     noPermissionGenerateThumbnail: 'Je hebt geen toestemming om miniaturen te genereren',
     noPermissionGenerateThumbnail: 'Je hebt geen toestemming om miniaturen te genereren',
     noPermissionDeleteFile: 'Je hebt geen toestemming om dit bestand te verwijderen',
     noPermissionDeleteFile: 'Je hebt geen toestemming om dit bestand te verwijderen',

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

@@ -3983,6 +3983,49 @@ export default {
     noPermissionDownload: 'Você não tem permissão para baixar arquivos',
     noPermissionDownload: 'Você não tem permissão para baixar arquivos',
     noPermissionPreview: 'Você não tem permissão para pré-visualizar arquivos',
     noPermissionPreview: 'Você não tem permissão para pré-visualizar arquivos',
     preview3d: 'Pré-visualização 3D',
     preview3d: 'Pré-visualização 3D',
+    // Pré-visualizações de documentos no app (#2976): arquivos PDF e planilhas.
+    preview: {
+      open: 'Pré-visualizar',
+      error: 'Este arquivo não pode ser pré-visualizado.',
+      tooLarge: 'O arquivo é muito grande para pré-visualizar ({{size}}).',
+      page: 'Página {{current}} de {{total}}',
+      prevPage: 'Página anterior',
+      nextPage: 'Próxima página',
+      zoomIn: 'Aumentar zoom',
+      zoomOut: 'Diminuir zoom',
+      emptySheet: 'Esta planilha está vazia',
+      truncatedRows: 'Mostrando as primeiras {{shown}} de {{total}} linhas',
+      truncatedCols: 'Mostrando as primeiras {{shown}} de {{total}} colunas',
+    },
+    details: {
+      title: 'Detalhes do arquivo',
+      size: 'Tamanho',
+      type: 'Tipo',
+      printName: 'Nome da impressão',
+      printTime: 'Tempo de impressão',
+      filament: 'Filamento',
+      slicedFor: 'Fatiado para',
+      source: 'Origem',
+      created: 'Criado',
+      modified: 'Modificado',
+      notes: 'Notas',
+      notesPlaceholder: 'Configurações de impressão, dicas, o que funcionou...',
+      externalLink: 'Link externo',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Abrir link',
+      photos: 'Fotos',
+      addPhoto: 'Adicionar foto',
+      noPhotos: 'Ainda não há fotos',
+      deletePhoto: 'Excluir foto',
+      photoCount_one: '{{count}} foto',
+      photoCount_other: '{{count}} fotos',
+      hasNotes: 'Tem notas',
+      save: 'Salvar',
+      saved: 'Detalhes do arquivo salvos',
+      saveFailed: 'Falha ao salvar os detalhes do arquivo',
+      uploadFailed: 'Falha ao enviar a foto',
+      deleteFailed: 'Falha ao excluir a foto',
+    },
     noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',
     noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',
     noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',
     noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',
     noPermissionDeleteFile: 'Você não tem permissão para excluir este arquivo',
     noPermissionDeleteFile: 'Você não tem permissão para excluir este arquivo',

+ 43 - 0
frontend/src/i18n/locales/ru.ts

@@ -3797,6 +3797,49 @@ export default {
     noPermissionDownload: "У вас нет прав на скачивание файлов",
     noPermissionDownload: "У вас нет прав на скачивание файлов",
     noPermissionPreview: 'У вас нет прав на предварительный просмотр файлов',
     noPermissionPreview: 'У вас нет прав на предварительный просмотр файлов',
     preview3d: '3D-просмотр',
     preview3d: '3D-просмотр',
+    // Встроенный предварительный просмотр документов (#2976): PDF и электронные таблицы.
+    preview: {
+      open: 'Предварительный просмотр',
+      error: 'Предварительный просмотр этого файла невозможен.',
+      tooLarge: 'Файл слишком большой для предварительного просмотра ({{size}}).',
+      page: 'Страница {{current}} из {{total}}',
+      prevPage: 'Предыдущая страница',
+      nextPage: 'Следующая страница',
+      zoomIn: 'Увеличить',
+      zoomOut: 'Уменьшить',
+      emptySheet: 'Этот лист пуст',
+      truncatedRows: 'Показаны первые {{shown}} из {{total}} строк',
+      truncatedCols: 'Показаны первые {{shown}} из {{total}} столбцов',
+    },
+    details: {
+      title: 'Сведения о файле',
+      size: 'Размер',
+      type: 'Тип',
+      printName: 'Название печати',
+      printTime: 'Время печати',
+      filament: 'Филамент',
+      slicedFor: 'Нарезано для',
+      source: 'Источник',
+      created: 'Создан',
+      modified: 'Изменён',
+      notes: 'Заметки',
+      notesPlaceholder: 'Настройки печати, советы, что сработало...',
+      externalLink: 'Внешняя ссылка',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Открыть ссылку',
+      photos: 'Фото',
+      addPhoto: 'Добавить фото',
+      noPhotos: 'Фото пока нет',
+      deletePhoto: 'Удалить фото',
+      photoCount_one: '{{count}} фото',
+      photoCount_other: '{{count}} фото',
+      hasNotes: 'Есть заметки',
+      save: 'Сохранить',
+      saved: 'Сведения о файле сохранены',
+      saveFailed: 'Не удалось сохранить сведения о файле',
+      uploadFailed: 'Не удалось загрузить фото',
+      deleteFailed: 'Не удалось удалить фото',
+    },
     noPermissionRenameFile: "У вас нет прав на переименование этого файла",
     noPermissionRenameFile: "У вас нет прав на переименование этого файла",
     noPermissionGenerateThumbnail: "У вас нет прав на создание миниатюр",
     noPermissionGenerateThumbnail: "У вас нет прав на создание миниатюр",
     noPermissionDeleteFile: "У вас нет прав на удаление этого файла",
     noPermissionDeleteFile: "У вас нет прав на удаление этого файла",

+ 43 - 0
frontend/src/i18n/locales/sv.ts

@@ -4025,6 +4025,49 @@ errors: {
     noPermissionDownload: 'Du har inte behörighet att ladda ner filer',
     noPermissionDownload: 'Du har inte behörighet att ladda ner filer',
     noPermissionPreview: 'Du har inte behörighet att förhandsgranska filer',
     noPermissionPreview: 'Du har inte behörighet att förhandsgranska filer',
     preview3d: '3D-förhandsgranskning',
     preview3d: '3D-förhandsgranskning',
+    // Dokumentförhandsgranskning i appen (#2976): PDF- och kalkylbladsfiler.
+    preview: {
+      open: 'Förhandsgranska',
+      error: 'Den här filen kan inte förhandsgranskas.',
+      tooLarge: 'Filen är för stor för att förhandsgranskas ({{size}}).',
+      page: 'Sida {{current}} av {{total}}',
+      prevPage: 'Föregående sida',
+      nextPage: 'Nästa sida',
+      zoomIn: 'Zooma in',
+      zoomOut: 'Zooma ut',
+      emptySheet: 'Det här bladet är tomt',
+      truncatedRows: 'Visar de första {{shown}} av {{total}} raderna',
+      truncatedCols: 'Visar de första {{shown}} av {{total}} kolumnerna',
+    },
+    details: {
+      title: 'Filinformation',
+      size: 'Storlek',
+      type: 'Typ',
+      printName: 'Utskriftsnamn',
+      printTime: 'Utskriftstid',
+      filament: 'Filament',
+      slicedFor: 'Slicad för',
+      source: 'Källa',
+      created: 'Skapad',
+      modified: 'Ändrad',
+      notes: 'Anteckningar',
+      notesPlaceholder: 'Utskriftsinställningar, tips, vad som fungerade...',
+      externalLink: 'Extern länk',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Öppna länk',
+      photos: 'Foton',
+      addPhoto: 'Lägg till foto',
+      noPhotos: 'Inga foton än',
+      deletePhoto: 'Ta bort foto',
+      photoCount_one: '{{count}} foto',
+      photoCount_other: '{{count}} foton',
+      hasNotes: 'Har anteckningar',
+      save: 'Spara',
+      saved: 'Filinformationen har sparats',
+      saveFailed: 'Filinformationen kunde inte sparas',
+      uploadFailed: 'Fotot kunde inte laddas upp',
+      deleteFailed: 'Fotot kunde inte tas bort',
+    },
     noPermissionRenameFile: 'Du har inte behörighet att döpa om denna fil',
     noPermissionRenameFile: 'Du har inte behörighet att döpa om denna fil',
     noPermissionGenerateThumbnail: 'Du har inte behörighet att generera minibilder',
     noPermissionGenerateThumbnail: 'Du har inte behörighet att generera minibilder',
     noPermissionDeleteFile: 'Du har inte behörighet att ta bort denna fil',
     noPermissionDeleteFile: 'Du har inte behörighet att ta bort denna fil',

+ 43 - 0
frontend/src/i18n/locales/tr.ts

@@ -3990,6 +3990,49 @@ export default {
     noPermissionDownload: 'Dosyaları indirme izniniz yok',
     noPermissionDownload: 'Dosyaları indirme izniniz yok',
     noPermissionPreview: 'Dosyaları önizleme izniniz yok',
     noPermissionPreview: 'Dosyaları önizleme izniniz yok',
     preview3d: '3B Önizleme',
     preview3d: '3B Önizleme',
+    // Uygulama içi belge önizlemeleri (#2976): PDF ve elektronik tablo dosyaları.
+    preview: {
+      open: 'Önizleme',
+      error: 'Bu dosya önizlenemiyor.',
+      tooLarge: 'Dosya önizleme için çok büyük ({{size}}).',
+      page: 'Sayfa {{current}} / {{total}}',
+      prevPage: 'Önceki sayfa',
+      nextPage: 'Sonraki sayfa',
+      zoomIn: 'Yakınlaştır',
+      zoomOut: 'Uzaklaştır',
+      emptySheet: 'Bu sayfa boş',
+      truncatedRows: '{{total}} satırdan ilk {{shown}} tanesi gösteriliyor',
+      truncatedCols: '{{total}} sütundan ilk {{shown}} tanesi gösteriliyor',
+    },
+    details: {
+      title: 'Dosya ayrıntıları',
+      size: 'Boyut',
+      type: 'Tür',
+      printName: 'Baskı adı',
+      printTime: 'Baskı süresi',
+      filament: 'Filament',
+      slicedFor: 'Dilimlenen yazıcı',
+      source: 'Kaynak',
+      created: 'Oluşturulma',
+      modified: 'Değiştirilme',
+      notes: 'Notlar',
+      notesPlaceholder: 'Baskı ayarları, ipuçları, işe yarayanlar...',
+      externalLink: 'Harici bağlantı',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Bağlantıyı aç',
+      photos: 'Fotoğraflar',
+      addPhoto: 'Fotoğraf ekle',
+      noPhotos: 'Henüz fotoğraf yok',
+      deletePhoto: 'Fotoğrafı sil',
+      photoCount_one: '{{count}} fotoğraf',
+      photoCount_other: '{{count}} fotoğraf',
+      hasNotes: 'Not içeriyor',
+      save: 'Kaydet',
+      saved: 'Dosya ayrıntıları kaydedildi',
+      saveFailed: 'Dosya ayrıntıları kaydedilemedi',
+      uploadFailed: 'Fotoğraf yüklenemedi',
+      deleteFailed: 'Fotoğraf silinemedi',
+    },
     noPermissionRenameFile: 'Bu dosyayı yeniden adlandırma izniniz yok',
     noPermissionRenameFile: 'Bu dosyayı yeniden adlandırma izniniz yok',
     noPermissionGenerateThumbnail: 'Küçük resim oluşturma izniniz yok',
     noPermissionGenerateThumbnail: 'Küçük resim oluşturma izniniz yok',
     noPermissionDeleteFile: 'Bu dosyayı silme izniniz yok',
     noPermissionDeleteFile: 'Bu dosyayı silme izniniz yok',

+ 43 - 0
frontend/src/i18n/locales/uk.ts

@@ -4023,6 +4023,49 @@ export default {
     noPermissionDownload: "У вас немає дозволу на завантаження файлів",
     noPermissionDownload: "У вас немає дозволу на завантаження файлів",
     noPermissionPreview: 'У вас немає дозволу на попередній перегляд файлів',
     noPermissionPreview: 'У вас немає дозволу на попередній перегляд файлів',
     preview3d: '3D-перегляд',
     preview3d: '3D-перегляд',
+    // Попередній перегляд документів у застосунку (#2976): PDF та електронні таблиці.
+    preview: {
+      open: 'Попередній перегляд',
+      error: 'Цей файл неможливо переглянути.',
+      tooLarge: 'Файл завеликий для попереднього перегляду ({{size}}).',
+      page: 'Сторінка {{current}} з {{total}}',
+      prevPage: 'Попередня сторінка',
+      nextPage: 'Наступна сторінка',
+      zoomIn: 'Збільшити',
+      zoomOut: 'Зменшити',
+      emptySheet: 'Цей аркуш порожній',
+      truncatedRows: 'Показано перші {{shown}} з {{total}} рядків',
+      truncatedCols: 'Показано перші {{shown}} з {{total}} стовпців',
+    },
+    details: {
+      title: 'Відомості про файл',
+      size: 'Розмір',
+      type: 'Тип',
+      printName: 'Назва друку',
+      printTime: 'Час друку',
+      filament: 'Філамент',
+      slicedFor: 'Нарізано для',
+      source: 'Джерело',
+      created: 'Створено',
+      modified: 'Змінено',
+      notes: 'Нотатки',
+      notesPlaceholder: 'Налаштування друку, поради, що спрацювало...',
+      externalLink: 'Зовнішнє посилання',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: 'Відкрити посилання',
+      photos: 'Фото',
+      addPhoto: 'Додати фото',
+      noPhotos: 'Фото поки немає',
+      deletePhoto: 'Видалити фото',
+      photoCount_one: '{{count}} фото',
+      photoCount_other: '{{count}} фото',
+      hasNotes: 'Є нотатки',
+      save: 'Зберегти',
+      saved: 'Відомості про файл збережено',
+      saveFailed: 'Не вдалося зберегти відомості про файл',
+      uploadFailed: 'Не вдалося завантажити фото',
+      deleteFailed: 'Не вдалося видалити фото',
+    },
     noPermissionRenameFile: "Ви не маєте дозволу на перейменування цього файлу",
     noPermissionRenameFile: "Ви не маєте дозволу на перейменування цього файлу",
     noPermissionGenerateThumbnail: "Ви не маєте дозволу створювати мініатюри",
     noPermissionGenerateThumbnail: "Ви не маєте дозволу створювати мініатюри",
     noPermissionDeleteFile: "Ви не маєте дозволу на видалення цього файлу",
     noPermissionDeleteFile: "Ви не маєте дозволу на видалення цього файлу",

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

@@ -3983,6 +3983,49 @@ export default {
     noPermissionDownload: '您没有下载文件的权限',
     noPermissionDownload: '您没有下载文件的权限',
     noPermissionPreview: '您没有预览文件的权限',
     noPermissionPreview: '您没有预览文件的权限',
     preview3d: '3D 预览',
     preview3d: '3D 预览',
+    // 应用内文档预览(#2976):PDF 和电子表格文件。
+    preview: {
+      open: '预览',
+      error: '此文件无法预览。',
+      tooLarge: '文件过大,无法预览({{size}})。',
+      page: '第 {{current}} 页,共 {{total}} 页',
+      prevPage: '上一页',
+      nextPage: '下一页',
+      zoomIn: '放大',
+      zoomOut: '缩小',
+      emptySheet: '此工作表为空',
+      truncatedRows: '仅显示前 {{shown}} 行,共 {{total}} 行',
+      truncatedCols: '仅显示前 {{shown}} 列,共 {{total}} 列',
+    },
+    details: {
+      title: '文件详情',
+      size: '大小',
+      type: '类型',
+      printName: '打印名称',
+      printTime: '打印时间',
+      filament: '耗材',
+      slicedFor: '切片目标',
+      source: '来源',
+      created: '创建时间',
+      modified: '修改时间',
+      notes: '备注',
+      notesPlaceholder: '打印设置、技巧、有效的做法...',
+      externalLink: '外部链接',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: '打开链接',
+      photos: '照片',
+      addPhoto: '添加照片',
+      noPhotos: '暂无照片',
+      deletePhoto: '删除照片',
+      photoCount_one: '{{count}} 张照片',
+      photoCount_other: '{{count}} 张照片',
+      hasNotes: '有备注',
+      save: '保存',
+      saved: '文件详情已保存',
+      saveFailed: '文件详情保存失败',
+      uploadFailed: '照片上传失败',
+      deleteFailed: '照片删除失败',
+    },
     noPermissionRenameFile: '您没有重命名此文件的权限',
     noPermissionRenameFile: '您没有重命名此文件的权限',
     noPermissionGenerateThumbnail: '您没有生成缩略图的权限',
     noPermissionGenerateThumbnail: '您没有生成缩略图的权限',
     noPermissionDeleteFile: '您没有删除此文件的权限',
     noPermissionDeleteFile: '您没有删除此文件的权限',

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

@@ -3983,6 +3983,49 @@ export default {
     noPermissionDownload: '您沒有下載檔案的權限',
     noPermissionDownload: '您沒有下載檔案的權限',
     noPermissionPreview: '您沒有預覽檔案的權限',
     noPermissionPreview: '您沒有預覽檔案的權限',
     preview3d: '3D 預覽',
     preview3d: '3D 預覽',
+    // 應用程式內文件預覽(#2976):PDF 與試算表檔案。
+    preview: {
+      open: '預覽',
+      error: '此檔案無法預覽。',
+      tooLarge: '檔案過大,無法預覽({{size}})。',
+      page: '第 {{current}} 頁,共 {{total}} 頁',
+      prevPage: '上一頁',
+      nextPage: '下一頁',
+      zoomIn: '放大',
+      zoomOut: '縮小',
+      emptySheet: '此工作表是空的',
+      truncatedRows: '顯示前 {{shown}} 列,共 {{total}} 列',
+      truncatedCols: '顯示前 {{shown}} 欄,共 {{total}} 欄',
+    },
+    details: {
+      title: '檔案詳細資料',
+      size: '大小',
+      type: '類型',
+      printName: '列印名稱',
+      printTime: '列印時間',
+      filament: '線材',
+      slicedFor: '切片目標',
+      source: '來源',
+      created: '建立時間',
+      modified: '修改時間',
+      notes: '備註',
+      notesPlaceholder: '列印設定、技巧、有效的做法...',
+      externalLink: '外部連結',
+      externalLinkPlaceholder: 'https://printables.com/model/...',
+      openLink: '開啟連結',
+      photos: '照片',
+      addPhoto: '新增照片',
+      noPhotos: '尚無照片',
+      deletePhoto: '刪除照片',
+      photoCount_one: '{{count}} 張照片',
+      photoCount_other: '{{count}} 張照片',
+      hasNotes: '有備註',
+      save: '儲存',
+      saved: '檔案詳細資料已儲存',
+      saveFailed: '檔案詳細資料儲存失敗',
+      uploadFailed: '照片上傳失敗',
+      deleteFailed: '照片刪除失敗',
+    },
     noPermissionRenameFile: '您沒有重新命名此檔案的權限',
     noPermissionRenameFile: '您沒有重新命名此檔案的權限',
     noPermissionGenerateThumbnail: '您沒有產生縮圖的權限',
     noPermissionGenerateThumbnail: '您沒有產生縮圖的權限',
     noPermissionDeleteFile: '您沒有刪除此檔案的權限',
     noPermissionDeleteFile: '您沒有刪除此檔案的權限',

+ 241 - 7
frontend/src/pages/FileManagerPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
+import { useState, useRef, useCallback, useMemo, useEffect, lazy, Suspense } from 'react';
 import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { Link, useNavigate, useSearchParams } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -45,6 +45,12 @@ import {
   Lock,
   Lock,
   FolderSymlink,
   FolderSymlink,
   Tag as TagIcon,
   Tag as TagIcon,
+  FileText,
+  FileSpreadsheet,
+  Info,
+  Globe,
+  StickyNote,
+  Camera,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -68,6 +74,7 @@ import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
 import { LibraryTagsModal } from '../components/LibraryTagsModal';
 import { LibraryTagsModal } from '../components/LibraryTagsModal';
+import { LibraryFileDetailsModal } from '../components/LibraryFileDetailsModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
@@ -82,6 +89,23 @@ type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
 
+// Document previews (#2976) are code-split: pdf.js and the spreadsheet
+// parsers only load when a preview is actually opened.
+const PdfPreviewModal = lazy(() =>
+  import('../components/PdfPreviewModal').then((m) => ({ default: m.PdfPreviewModal }))
+);
+const SpreadsheetPreviewModal = lazy(() =>
+  import('../components/SpreadsheetPreviewModal').then((m) => ({ default: m.SpreadsheetPreviewModal }))
+);
+
+function isSpreadsheetType(fileType: string): boolean {
+  return fileType === 'csv' || fileType === 'xlsx' || fileType === 'ods';
+}
+
+function isStepType(fileType: string): boolean {
+  return fileType === 'step' || fileType === 'stp';
+}
+
 // New Folder Modal
 // New Folder Modal
 interface NewFolderModalProps {
 interface NewFolderModalProps {
   parentId: number | null;
   parentId: number | null;
@@ -761,7 +785,9 @@ interface FileCardProps {
   desktopSlicer: SlicerType;
   desktopSlicer: SlicerType;
   canSlice?: boolean;
   canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
   onPreview3d?: (file: LibraryFileListItem) => void;
+  onPreviewDocument?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
+  onDetails?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onTagClick?: (tagId: number) => void;
   onTagClick?: (tagId: number) => void;
   thumbnailVersion?: number;
   thumbnailVersion?: number;
@@ -772,7 +798,7 @@ interface FileCardProps {
   t: TFunction;
   t: TFunction;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
+function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, desktopSlicer, canSlice, onPreview3d, onPreviewDocument, onRename, onDetails, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // Viewport coordinates rather than a flag, because the menu is rendered by
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // `ContextMenu` at `position: fixed` and anchored to the button (#2846). The
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
   // card it belongs to is only ~270px tall for a bare STL, which is shorter
@@ -814,7 +840,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
       title: !hasPermission('pipelines:run') ? t('library.runWithPipeline.noPermission') : undefined,
     });
     });
   }
   }
-  if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf')) {
+  if (onPreview3d && (file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'stl' || file.file_type === 'gcode.3mf' || isStepType(file.file_type))) {
     menuItems.push({
     menuItems.push({
       label: t('fileManager.preview3d'),
       label: t('fileManager.preview3d'),
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
@@ -823,6 +849,15 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
       title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
     });
     });
   }
   }
+  if (onPreviewDocument && (file.file_type === 'pdf' || isSpreadsheetType(file.file_type))) {
+    menuItems.push({
+      label: t('fileManager.preview.open'),
+      icon: file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />,
+      onClick: () => onPreviewDocument(file),
+      disabled: !canPreview3d,
+      title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
+    });
+  }
   menuItems.push({
   menuItems.push({
     label: t('common.download'),
     label: t('common.download'),
     icon: <Download className="w-4 h-4" />,
     icon: <Download className="w-4 h-4" />,
@@ -839,6 +874,24 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       title: !canRename ? t('fileManager.noPermissionRenameFile') : undefined,
       title: !canRename ? t('fileManager.noPermissionRenameFile') : undefined,
     });
     });
   }
   }
+  if (onDetails) {
+    menuItems.push({
+      label: t('fileManager.details.title'),
+      icon: <Info className="w-4 h-4" />,
+      onClick: () => onDetails(file),
+      disabled: !canPreview3d,
+      title: !canPreview3d ? t('fileManager.noPermissionPreview') : undefined,
+    });
+  }
+  if (file.external_url) {
+    menuItems.push({
+      label: t('fileManager.details.openLink'),
+      icon: <Globe className="w-4 h-4" />,
+      // The URL is stored by whoever owns the file, so the opened page must
+      // not get a handle on this window (#3077).
+      onClick: () => window.open(file.external_url!, '_blank', 'noopener,noreferrer'),
+    });
+  }
   if (onGenerateThumbnail && file.file_type === 'stl') {
   if (onGenerateThumbnail && file.file_type === 'stl') {
     menuItems.push({
     menuItems.push({
       label: t('fileManager.generateThumbnail'),
       label: t('fileManager.generateThumbnail'),
@@ -874,6 +927,10 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
             alt={file.filename}
             alt={file.filename}
             className="w-full h-full object-cover"
             className="w-full h-full object-cover"
           />
           />
+        ) : file.file_type === 'pdf' ? (
+          <FileText className="w-12 h-12 text-bambu-gray/30" />
+        ) : isSpreadsheetType(file.file_type) ? (
+          <FileSpreadsheet className="w-12 h-12 text-bambu-gray/30" />
         ) : (
         ) : (
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
         )}
         )}
@@ -884,6 +941,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           // that the file is already sliced and ready to print (#1543).
           // that the file is already sliced and ready to print (#1543).
           : file.file_type === 'gcode' || file.file_type === 'gcode.3mf' ? 'bg-blue-500/90 text-white'
           : file.file_type === 'gcode' || file.file_type === 'gcode.3mf' ? 'bg-blue-500/90 text-white'
           : file.file_type === 'stl' ? 'bg-purple-500/90 text-white'
           : file.file_type === 'stl' ? 'bg-purple-500/90 text-white'
+          : isStepType(file.file_type) ? 'bg-amber-500/90 text-white'
+          : file.file_type === 'pdf' ? 'bg-red-500/90 text-white'
+          : isSpreadsheetType(file.file_type) ? 'bg-teal-500/90 text-white'
           : 'bg-bambu-gray/90 text-white'
           : 'bg-bambu-gray/90 text-white'
         }`}>
         }`}>
           {file.file_type.toUpperCase()}
           {file.file_type.toUpperCase()}
@@ -923,6 +983,47 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
             {t('fileManager.printedCount', { count: file.print_count })}
             {t('fileManager.printedCount', { count: file.print_count })}
           </div>
           </div>
         )}
         )}
+        {/* Metadata indicators (#3077): link, notes, photos. The link opens in
+            a new tab like the archive card's globe; the others open Details. */}
+        {(file.external_url || file.has_notes || (file.photo_count ?? 0) > 0) && (
+          <div className="mt-1 flex items-center gap-2 text-xs text-bambu-gray" onClick={(e) => e.stopPropagation()}>
+            {file.external_url && (
+              <a
+                href={file.external_url}
+                target="_blank"
+                rel="noopener noreferrer"
+                className="p-0.5 rounded hover:text-bambu-green"
+                title={t('fileManager.details.openLink')}
+                aria-label={t('fileManager.details.openLink')}
+              >
+                <Globe className="w-3.5 h-3.5" />
+              </a>
+            )}
+            {file.has_notes && (
+              <button
+                type="button"
+                onClick={() => onDetails?.(file)}
+                className="p-0.5 rounded hover:text-bambu-green"
+                title={t('fileManager.details.hasNotes')}
+                aria-label={t('fileManager.details.hasNotes')}
+              >
+                <StickyNote className="w-3.5 h-3.5" />
+              </button>
+            )}
+            {(file.photo_count ?? 0) > 0 && (
+              <button
+                type="button"
+                onClick={() => onDetails?.(file)}
+                className="flex items-center gap-0.5 p-0.5 rounded hover:text-bambu-green"
+                title={t('fileManager.details.photoCount', { count: file.photo_count })}
+                aria-label={t('fileManager.details.photoCount', { count: file.photo_count })}
+              >
+                <Camera className="w-3.5 h-3.5" />
+                <span>{file.photo_count}</span>
+              </button>
+            )}
+          </div>
+        )}
         {authEnabled && file.created_by_username && (
         {authEnabled && file.created_by_username && (
           <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
           <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
             <User className="w-3 h-3" />
             <User className="w-3 h-3" />
@@ -1026,6 +1127,9 @@ export function FileManagerPage() {
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
   const [viewerFile, setViewerFile] = useState<LibraryFileListItem | null>(null);
+  const [pdfPreviewFile, setPdfPreviewFile] = useState<LibraryFileListItem | null>(null);
+  const [sheetPreviewFile, setSheetPreviewFile] = useState<LibraryFileListItem | null>(null);
+  const [detailsFile, setDetailsFile] = useState<LibraryFileListItem | null>(null);
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
   });
@@ -1646,6 +1750,30 @@ export function FileManagerPage() {
     },
     },
   });
   });
 
 
+  // Returns the snapshot callback the preview components call with their
+  // first render, or undefined when nothing should be persisted — the file
+  // already has a thumbnail, or the user may not update it (#2976).
+  const previewSnapshotHandler = useCallback(
+    (file: LibraryFileListItem): ((blob: Blob) => void) | undefined => {
+      if (file.thumbnail_path) return undefined;
+      if (!canModify('library', 'update', file.created_by_id)) return undefined;
+      return (blob: Blob) => {
+        api
+          .uploadLibraryPreviewThumbnail(file.id, blob)
+          .then((res) => {
+            if (res.updated) {
+              setThumbnailVersions((prev) => ({ ...prev, [file.id]: (prev[file.id] || 0) + 1 }));
+              queryClient.invalidateQueries({ queryKey: ['library-files'] });
+            }
+          })
+          .catch(() => {
+            // Thumbnail persistence is best-effort; the preview already rendered.
+          });
+      };
+    },
+    [canModify, queryClient]
+  );
+
   const handleDownload = (id: number) => {
   const handleDownload = (id: number) => {
     api.downloadLibraryFile(id).catch((err) => {
     api.downloadLibraryFile(id).catch((err) => {
       console.error('Library file download failed:', err);
       console.error('Library file download failed:', err);
@@ -2447,7 +2575,12 @@ export function FileManagerPage() {
                         setViewerFile(f);
                         setViewerFile(f);
                       }
                       }
                     }}
                     }}
+                    onPreviewDocument={(f) => {
+                      if (f.file_type === 'pdf') setPdfPreviewFile(f);
+                      else setSheetPreviewFile(f);
+                    }}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
+                    onDetails={setDetailsFile}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onTagClick={toggleTagFilter}
                     onTagClick={toggleTagFilter}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     thumbnailVersion={thumbnailVersions[file.id]}
@@ -2512,7 +2645,13 @@ export function FileManagerPage() {
                             />
                             />
                           ) : (
                           ) : (
                             <div className="w-full h-full flex items-center justify-center">
                             <div className="w-full h-full flex items-center justify-center">
-                              <FileBox className="w-5 h-5 text-bambu-gray/50" />
+                              {file.file_type === 'pdf' ? (
+                                <FileText className="w-5 h-5 text-bambu-gray/50" />
+                              ) : isSpreadsheetType(file.file_type) ? (
+                                <FileSpreadsheet className="w-5 h-5 text-bambu-gray/50" />
+                              ) : (
+                                <FileBox className="w-5 h-5 text-bambu-gray/50" />
+                              )}
                             </div>
                             </div>
                           )}
                           )}
                         </div>
                         </div>
@@ -2530,7 +2669,34 @@ export function FileManagerPage() {
                         )}
                         )}
                       </div>
                       </div>
                       <div className="min-w-0">
                       <div className="min-w-0">
-                        <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
+                        <div className="flex items-center gap-1.5 min-w-0">
+                          <span className="text-sm text-white truncate">{file.print_name || file.filename}</span>
+                          {/* Metadata indicators (#3077), same set as the card. */}
+                          {file.external_url && (
+                            <a
+                              href={file.external_url}
+                              target="_blank"
+                              rel="noopener noreferrer"
+                              onClick={(e) => e.stopPropagation()}
+                              className="flex-shrink-0 text-bambu-gray hover:text-bambu-green"
+                              title={t('fileManager.details.openLink')}
+                              aria-label={t('fileManager.details.openLink')}
+                            >
+                              <Globe className="w-3.5 h-3.5" />
+                            </a>
+                          )}
+                          {file.has_notes && (
+                            <span className="flex-shrink-0 text-bambu-gray" title={t('fileManager.details.hasNotes')} aria-label={t('fileManager.details.hasNotes')}>
+                              <StickyNote className="w-3.5 h-3.5" />
+                            </span>
+                          )}
+                          {(file.photo_count ?? 0) > 0 && (
+                            <span className="flex-shrink-0 flex items-center gap-0.5 text-xs text-bambu-gray" title={t('fileManager.details.photoCount', { count: file.photo_count })}>
+                              <Camera className="w-3.5 h-3.5" />
+                              {file.photo_count}
+                            </span>
+                          )}
+                        </div>
                         {/* #2680: last-modified date under the name, toggled from
                         {/* #2680: last-modified date under the name, toggled from
                             the toolbar. Real on-disk mtime when known, else created_at. */}
                             the toolbar. Real on-disk mtime when known, else created_at. */}
                         {showModified && (
                         {showModified && (
@@ -2560,6 +2726,9 @@ export function FileManagerPage() {
                         file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
                         file.file_type === '3mf' ? 'bg-bambu-green/20 text-bambu-green'
                         : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
                         : (file.file_type === 'gcode' || file.file_type === 'gcode.3mf') ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
                         : file.file_type === 'stl' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400'
                         : file.file_type === 'stl' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400'
+                        : isStepType(file.file_type) ? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400'
+                        : file.file_type === 'pdf' ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
+                        : isSpreadsheetType(file.file_type) ? 'bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400'
                         : 'bg-bambu-gray/20 text-bambu-gray'
                         : 'bg-bambu-gray/20 text-bambu-gray'
                       }`}>
                       }`}>
                         {file.file_type.toUpperCase()}
                         {file.file_type.toUpperCase()}
@@ -2642,7 +2811,7 @@ export function FileManagerPage() {
                           <Play className="w-4 h-4" />
                           <Play className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
-                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl') && (
+                      {(file.file_type === '3mf' || file.file_type === 'gcode' || file.file_type === 'gcode.3mf' || file.file_type === 'stl' || isStepType(file.file_type)) && (
                         <button
                         <button
                           onClick={() => {
                           onClick={() => {
                             if (!hasPermission('library:read')) return;
                             if (!hasPermission('library:read')) return;
@@ -2657,12 +2826,30 @@ export function FileManagerPage() {
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               : 'text-bambu-gray/50 cursor-not-allowed'
                               : 'text-bambu-gray/50 cursor-not-allowed'
                           }`}
                           }`}
-                          title={hasPermission('library:read') ? '3D Preview' : 'You do not have permission to preview files'}
+                          title={hasPermission('library:read') ? t('fileManager.preview3d') : t('fileManager.noPermissionPreview')}
                           disabled={!hasPermission('library:read')}
                           disabled={!hasPermission('library:read')}
                         >
                         >
                           <Box className="w-4 h-4" />
                           <Box className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
+                      {(file.file_type === 'pdf' || isSpreadsheetType(file.file_type)) && (
+                        <button
+                          onClick={() => {
+                            if (!hasPermission('library:read')) return;
+                            if (file.file_type === 'pdf') setPdfPreviewFile(file);
+                            else setSheetPreviewFile(file);
+                          }}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:read')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:read') ? t('fileManager.preview.open') : t('fileManager.noPermissionPreview')}
+                          disabled={!hasPermission('library:read')}
+                        >
+                          {file.file_type === 'pdf' ? <FileText className="w-4 h-4" /> : <FileSpreadsheet className="w-4 h-4" />}
+                        </button>
+                      )}
                       <button
                       <button
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         className={`p-1.5 rounded transition-colors ${
                         className={`p-1.5 rounded transition-colors ${
@@ -2675,6 +2862,18 @@ export function FileManagerPage() {
                       >
                       >
                         <Download className="w-4 h-4" />
                         <Download className="w-4 h-4" />
                       </button>
                       </button>
+                      <button
+                        onClick={() => hasPermission('library:read') && setDetailsFile(file)}
+                        className={`p-1.5 rounded transition-colors ${
+                          hasPermission('library:read')
+                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                            : 'text-bambu-gray/50 cursor-not-allowed'
+                        }`}
+                        title={hasPermission('library:read') ? t('fileManager.details.title') : t('fileManager.noPermissionPreview')}
+                        disabled={!hasPermission('library:read')}
+                      >
+                        <Info className="w-4 h-4" />
+                      </button>
                       <button
                       <button
                         onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
                         onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
                         className={`p-1.5 rounded transition-colors ${
                         className={`p-1.5 rounded transition-colors ${
@@ -2871,6 +3070,9 @@ export function FileManagerPage() {
           title={viewerFile.print_name || viewerFile.filename}
           title={viewerFile.print_name || viewerFile.filename}
           fileType={viewerFile.file_type}
           fileType={viewerFile.file_type}
           onClose={() => setViewerFile(null)}
           onClose={() => setViewerFile(null)}
+          // STEP has no server-side renderer; persist the first client render
+          // as the grid thumbnail (#2976).
+          onSnapshot={isStepType(viewerFile.file_type) ? previewSnapshotHandler(viewerFile) : undefined}
           onSliceWithBambuddy={
           onSliceWithBambuddy={
             // Only offer in-app slicing on files the SliceModal can actually
             // Only offer in-app slicing on files the SliceModal can actually
             // handle (matches the file-row Cog visibility check at :2127).
             // handle (matches the file-row Cog visibility check at :2127).
@@ -2885,6 +3087,38 @@ export function FileManagerPage() {
         />
         />
       )}
       )}
 
 
+      {(pdfPreviewFile || sheetPreviewFile) && (
+        <Suspense fallback={null}>
+          {pdfPreviewFile && (
+            <PdfPreviewModal
+              libraryFileId={pdfPreviewFile.id}
+              filename={pdfPreviewFile.print_name || pdfPreviewFile.filename}
+              fileSize={pdfPreviewFile.file_size}
+              onClose={() => setPdfPreviewFile(null)}
+              onSnapshot={previewSnapshotHandler(pdfPreviewFile)}
+            />
+          )}
+          {sheetPreviewFile && (
+            <SpreadsheetPreviewModal
+              libraryFileId={sheetPreviewFile.id}
+              filename={sheetPreviewFile.print_name || sheetPreviewFile.filename}
+              fileType={sheetPreviewFile.file_type}
+              fileSize={sheetPreviewFile.file_size}
+              onClose={() => setSheetPreviewFile(null)}
+              onSnapshot={previewSnapshotHandler(sheetPreviewFile)}
+            />
+          )}
+        </Suspense>
+      )}
+
+      {detailsFile && (
+        <LibraryFileDetailsModal
+          file={detailsFile}
+          canEdit={canModify('library', 'update', detailsFile.created_by_id)}
+          onClose={() => setDetailsFile(null)}
+        />
+      )}
+
       {renameItem && (
       {renameItem && (
         <RenameModal
         <RenameModal
           type={renameItem.type}
           type={renameItem.type}

+ 31 - 0
frontend/src/types/occt-import-js.d.ts

@@ -0,0 +1,31 @@
+// Hand-written types for occt-import-js (#2976) — the package ships none.
+// Only the surface the STEP preview uses is declared.
+declare module 'occt-import-js' {
+  export interface OcctAttributeArray {
+    array: number[];
+  }
+
+  export interface OcctMesh {
+    name?: string;
+    // Face colour as 0-1 RGB floats when the STEP file defines one.
+    color?: [number, number, number];
+    attributes: {
+      position: OcctAttributeArray;
+      normal?: OcctAttributeArray;
+    };
+    index: OcctAttributeArray;
+  }
+
+  export interface OcctImportResult {
+    success: boolean;
+    meshes: OcctMesh[];
+  }
+
+  export interface OcctInstance {
+    ReadStepFile: (content: Uint8Array, params: unknown) => OcctImportResult;
+  }
+
+  export default function occtimportjs(options?: {
+    locateFile?: (name: string) => string;
+  }): Promise<OcctInstance>;
+}

+ 77 - 0
frontend/src/workers/stepPreview.worker.ts

@@ -0,0 +1,77 @@
+/// <reference lib="webworker" />
+// STEP triangulation worker (#2976).
+//
+// OpenCascade compiled to WASM does the triangulation. It runs in a worker
+// for two reasons: parsing a real assembly takes seconds and would freeze
+// the UI thread, and — decisive — the emscripten/embind glue generates its
+// invoker functions with `new Function(...)`, which the app's nonce-strict
+// CSP rightly blocks on the document. Per CSP3 a dedicated worker is
+// governed by the policy delivered with this script's own response, so the
+// backend relaxes 'unsafe-eval' for exactly this asset and nothing else
+// (see security_headers_middleware in backend/app/main.py).
+import occtimportjs from 'occt-import-js';
+import type { OcctInstance } from 'occt-import-js';
+import wasmUrl from 'occt-import-js/dist/occt-import-js.wasm?url';
+
+export interface StepWorkerRequest {
+  /** Correlation id echoed on the response — the worker is shared. */
+  id: number;
+  buffer: ArrayBuffer;
+}
+
+export interface StepWorkerMesh {
+  positions: Float32Array;
+  normals: Float32Array | null;
+  indices: Uint32Array | null;
+  color: [number, number, number] | null;
+}
+
+export type StepWorkerResponse =
+  | { id: number; ok: true; meshes: StepWorkerMesh[] }
+  | { id: number; ok: false; reason: 'no-meshes' | 'error' };
+
+// The ~7 MB wasm instance is expensive to initialise — created once and kept
+// for the worker's lifetime. A failed init is not cached so a later preview
+// retries from scratch.
+let instancePromise: Promise<OcctInstance> | null = null;
+
+function getInstance(): Promise<OcctInstance> {
+  if (!instancePromise) {
+    instancePromise = occtimportjs({ locateFile: () => wasmUrl }).catch((err: unknown) => {
+      instancePromise = null;
+      throw err;
+    });
+  }
+  return instancePromise;
+}
+
+self.onmessage = async (event: MessageEvent<StepWorkerRequest>) => {
+  const { id } = event.data;
+  try {
+    const occt = await getInstance();
+    const result = occt.ReadStepFile(new Uint8Array(event.data.buffer), null);
+    if (!result.success || result.meshes.length === 0) {
+      self.postMessage({ id, ok: false, reason: 'no-meshes' } satisfies StepWorkerResponse);
+      return;
+    }
+    const meshes = result.meshes.map(
+      (mesh): StepWorkerMesh => ({
+        positions: new Float32Array(mesh.attributes.position.array),
+        normals: mesh.attributes.normal ? new Float32Array(mesh.attributes.normal.array) : null,
+        indices: mesh.index ? new Uint32Array(mesh.index.array) : null,
+        color: mesh.color ?? null,
+      })
+    );
+    // Transfer the typed-array buffers instead of structured-cloning them —
+    // large assemblies are tens of MB of vertex data.
+    const transfers = meshes.flatMap((mesh) => {
+      const buffers = [mesh.positions.buffer];
+      if (mesh.normals) buffers.push(mesh.normals.buffer);
+      if (mesh.indices) buffers.push(mesh.indices.buffer);
+      return buffers;
+    });
+    self.postMessage({ id, ok: true, meshes } satisfies StepWorkerResponse, transfers);
+  } catch {
+    self.postMessage({ id, ok: false, reason: 'error' } satisfies StepWorkerResponse);
+  }
+};

+ 8 - 0
frontend/vite.config.ts

@@ -22,6 +22,14 @@ export default defineConfig({
     outDir: '../static',
     outDir: '../static',
     emptyOutDir: true,
     emptyOutDir: true,
     chunkSizeWarningLimit: 3000,
     chunkSizeWarningLimit: 3000,
+    // Support floor is Safari 16.0 / iOS 16.0 (see
+    // scripts/check-browser-baseline.mjs, #2971). Without an explicit target
+    // the bundler keeps newer syntax verbatim — pdf.js ships class static
+    // initialisation blocks (Safari 16.4+), which would parse-fail the whole
+    // chunk on iOS 16.0-16.3 (#2976). This lowers such syntax at build time;
+    // regex features are NOT lowered, which is why the baseline check script
+    // still exists alongside this setting.
+    target: 'safari16',
   },
   },
   server: {
   server: {
     host: '0.0.0.0',
     host: '0.0.0.0',