Просмотр исходного кода

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

Thomansky 2 дней назад
Родитель
Сommit
12dddada0a
49 измененных файлов с 4817 добавлено и 57 удалено
  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,
     BulkDeleteRequest,
     BulkDeleteResponse,
+    ClientThumbnailResponse,
     ExternalFolderCreate,
     FileDuplicate,
     FileListResponse,
@@ -90,6 +91,7 @@ from backend.app.utils.filename import (
     safe_path_component,
     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.safe_path import PathTraversalError, assert_under, safe_join_under
 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
 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:
     """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 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)
     await db.delete(folder)
@@ -1588,6 +1615,12 @@ _SCANNABLE_EXTENSIONS = {
     ".webp",
     ".svg",
     ".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()
                 except OSError:
                     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)
             removed += 1
 
@@ -2229,6 +2266,9 @@ async def list_files(
                 tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
                 variant_group_id=f.variant_group_id,
                 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,
         last_printed_at=file.last_printed_at,
         notes=file.notes,
+        external_url=file.external_url,
+        photos=list(file.photos or []),
+        source_url=file.source_url,
         duplicates=duplicates if duplicates else None,
         duplicate_count=duplicate_count,
         created_by_id=file.created_by_id,
@@ -5132,6 +5175,9 @@ async def update_file(
     if data.notes is not 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.refresh(file)
 
@@ -5186,6 +5232,7 @@ async def delete_file(
 
         await delete_dependent_variants(db, [file.id])
         await release_queue_references(db, [file.id])
+        remove_library_photos_dir(file.id)
         await db.delete(file)
         await db.commit()
         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)
 
 
+@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")
 async def get_gcode(
     file_id: int,
@@ -5558,6 +5817,7 @@ async def bulk_delete(
         await delete_dependent_variants(db, hard_deleted_ids)
         await release_queue_references(db, hard_deleted_ids)
         for file in hard_deleted:
+            remove_library_photos_dir(file.id)
             await db.delete(file)
 
     # 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)
             await delete_dependent_variants(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)
             deleted_folders += 1
 

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

@@ -50,6 +50,7 @@ from backend.app.services.email_service import (
     send_email,
 )
 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"])
 
@@ -444,7 +445,14 @@ async def delete_user(
             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:
+        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
         await db.execute(delete(PrintArchive).where(PrintArchive.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.commit()
 
+    for file_id in doomed_library_file_ids:
+        remove_library_photos_dir(file_id)
+
 
 @router.post("/me/change-password", response_model=dict)
 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"
     )
 
+    # 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
     # 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

+ 30 - 1
backend/app/main.py

@@ -4,6 +4,7 @@ import logging
 import math
 import os
 import posixpath
+import re
 import secrets
 import time
 from contextlib import asynccontextmanager
@@ -9609,6 +9610,12 @@ def _frame_ancestors(default_value: str) -> str:
     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")
 async def security_headers_middleware(request, call_next):
     """Add standard HTTP security headers to every response."""
@@ -9655,6 +9662,23 @@ async def security_headers_middleware(request, call_next):
             "object-src '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:
         # The streaming overlay is embedded same-origin by the URL builder's
         # 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
         # allowlist in.
         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"] = (
             "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'; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "

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

@@ -147,6 +147,12 @@ class LibraryFile(Base):
     # User notes
     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.
     # MakerWorld), ``source_type`` identifies the source and ``source_url`` is
     # 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 pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
 
 # ============ Folder Schemas ============
 
@@ -122,6 +122,20 @@ class FileUpdate(BaseModel):
     folder_id: int | None = None
     project_id: int | 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):
@@ -157,6 +171,11 @@ class FileResponse(BaseModel):
     last_printed_at: datetime | 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
     duplicates: list[FileDuplicate] | None = None
@@ -227,6 +246,12 @@ class FileListResponse(BaseModel):
     variant_group_id: int | None = None
     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:
         from_attributes = True
 
@@ -413,6 +438,16 @@ class BatchThumbnailResponse(BaseModel):
     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) ============
 
 

+ 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.print_queue import PrintQueueItem, PrintQueueVariant
 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
 
 logger = logging.getLogger(__name__)
@@ -364,7 +365,7 @@ class LibraryTrashService:
 
     @staticmethod
     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):
             abs_path = _to_absolute_path(rel)
             if abs_path is None:
@@ -374,6 +375,7 @@ class LibraryTrashService:
                     abs_path.unlink()
             except OSError as e:
                 logger.warning("Trash sweep: failed to unlink %s: %s", abs_path, e)
+        remove_library_photos_dir(row.id)
 
     # ---- 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.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.filament_types import canonical_filament_type
 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.printer_models import (
     is_dual_nozzle_model,
@@ -6351,6 +6353,10 @@ class PrintScheduler:
         file_path = None
         filename = None
         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:
             # Print from archive
@@ -6452,6 +6458,9 @@ class PrintScheduler:
                             archive_id=archive.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)
                         file_path = settings.base_dir / archive.file_path
                         filename = archive.filename
@@ -6493,6 +6502,68 @@ class PrintScheduler:
                 await self._power_off_if_needed(db, item)
                 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:
             # Neither archive nor library file specified
             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
 
+    # ========================================================================
+    # 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
     # 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}"
 
 
+# ─── #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) ───
 
 

+ 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"),
     ("ExternalLinkCreate", "url"),  # sidebar link, rendered in the UI, never requested
     ("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
     ("MaintenanceTypeUpdate", "wiki_url"),
     ("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 pathlib import Path
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 
 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
 
 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.printer import Printer
 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
 
 
@@ -27,12 +31,13 @@ async def queue_factory(tmp_path):
     session_maker = async_sessionmaker(engine, expire_on_commit=False)
     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
         case_counter += 1
 
         base_dir = tmp_path / f"case-{case_counter}"
         base_dir.mkdir()
+        archive_dir = base_dir / "archive"
         source_path = base_dir / "library" / f"source-{case_counter}.3mf"
         source_path.parent.mkdir()
         source_path.write_bytes(b"library source")
@@ -70,10 +75,21 @@ async def queue_factory(tmp_path):
                 thumbnail_path=thumbnail_db_path,
                 file_metadata=None,
                 is_external=is_external,
+                photos=list(photos) or None,
             )
             db.add_all([printer, library_file])
             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(
                 printer_id=printer.id,
                 library_file_id=library_file.id,
@@ -152,7 +168,10 @@ async def queue_factory(tmp_path):
             return SimpleNamespace(
                 session_maker=session_maker,
                 base_dir=base_dir,
+                archive_dir=archive_dir,
                 source_path=source_path,
+                photos_dir=photos_dir,
+                photo_names=list(photos),
                 thumbnail_path=thumbnail_actual_path,
                 printer_id=printer.id,
                 library_file_id=library_file.id,
@@ -170,7 +189,14 @@ async def queue_factory(tmp_path):
         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()
 
     async def archive_print(
@@ -213,6 +239,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
 
     patches = [
         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.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
         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)
 
         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)
             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 with ctx.session_maker() as db:
         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()
 
 
+@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
 async def test_archive_creation_failure_skips_cleanup_and_dispatch(queue_factory):
     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-table": "^2.1.1",
         "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",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
@@ -43,7 +46,8 @@
         "react-router-dom": "7.18.2",
         "react-simple-keyboard": "^3.8.164",
         "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": {
         "@eslint/js": "^9.39.1",
@@ -52,6 +56,7 @@
         "@testing-library/react": "^16.0.0",
         "@testing-library/user-event": "^14.5.0",
         "@types/node": "^24.10.1",
+        "@types/papaparse": "^5.5.2",
         "@types/react": "^19.2.5",
         "@types/react-dom": "^19.2.3",
         "@vitejs/plugin-react": "^5.2.0",
@@ -1006,6 +1011,271 @@
         "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": {
       "version": "1.1.5",
       "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -2461,6 +2731,16 @@
         "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": {
       "version": "19.2.13",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
@@ -6307,6 +6587,12 @@
         "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": {
       "version": "0.9.4",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6376,6 +6662,12 @@
       "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
       "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": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6459,6 +6751,18 @@
       "dev": true,
       "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": {
       "version": "1.1.1",
       "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": {
       "version": "5.0.0",
       "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-table": "^2.1.1",
     "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",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
@@ -51,7 +54,8 @@
     "react-router-dom": "7.18.2",
     "react-simple-keyboard": "^3.8.164",
     "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": {
     "minimatch": "^10.2.1",
@@ -67,6 +71,7 @@
     "@testing-library/react": "^16.0.0",
     "@testing-library/user-event": "^14.5.0",
     "@types/node": "^24.10.1",
+    "@types/papaparse": "^5.5.2",
     "@types/react": "^19.2.5",
     "@types/react-dom": "^19.2.3",
     "@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
   'Simple', 'Expert',  // slicer settings visibility tiers — identical words in French
   'Support',  // same word in French
+  'Photos', '{{count}} photo', '{{count}} photos',  // file details photo strip (#3077) — same word in French
 ];
 
 // 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);
   },
   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) =>
     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`,
   moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
     request<{ status: string; moved: number }>('/library/files/move', {
@@ -8046,6 +8092,11 @@ export interface LibraryFile {
   print_count: number;
   last_printed_at: 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;
   duplicate_count: number;
   // User tracking (Issue #206)
@@ -8096,6 +8147,11 @@ export interface LibraryFileListItem {
   // matching rows on screen. 0 when the file is not grouped.
   variant_group_id?: number | null;
   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.
@@ -8133,6 +8189,7 @@ export interface LibraryFileUpdate {
   folder_id?: number | null;
   project_id?: number | null;
   notes?: string | null;
+  external_url?: string | null;
 }
 
 // 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 { Button } from './Button';
 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.
@@ -61,8 +115,15 @@ interface ModelViewerProps {
   filamentColors?: string[];
   selectedPlateId?: number | null;
   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 {
   vertices: number[];
   triangles: number[];
@@ -666,6 +727,41 @@ function buildModelGroup(
   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({
   url,
   fileType,
@@ -673,6 +769,7 @@ export function ModelViewer({
   filamentColors,
   selectedPlateId = null,
   className = '',
+  onSnapshot,
 }: ModelViewerProps) {
   const { t } = useTranslation();
   const containerRef = useRef<HTMLDivElement>(null);
@@ -693,6 +790,14 @@ export function ModelViewer({
   const [error, setError] = useState<string | null>(null);
   const [parsedData, setParsedData] = useState<Parsed3MFData | 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(() => {
     if (!containerRef.current) return;
@@ -819,6 +924,8 @@ export function ModelViewer({
     setError(null);
     setParsedData(null);
     setStlGeometry(null);
+    setStepMeshes(null);
+    snapshotSentRef.current = false;
 
     const normalizedType = (fileType || url.split('?')[0].split('.').pop() || '').toLowerCase();
 
@@ -863,6 +970,27 @@ export function ModelViewer({
           setError(err.message);
           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 {
       setError(t('modelViewer.errors.unsupportedFormat'));
       setLoading(false);
@@ -909,15 +1037,17 @@ export function ModelViewer({
 
   useEffect(() => {
     if (!sceneRef.current || !cameraRef.current || !controlsRef.current) return;
-    if (!parsedData && !stlGeometry) return;
+    if (!parsedData && !stlGeometry && !stepMeshes) return;
 
     if (modelGroupRef.current) {
       sceneRef.current.remove(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 material = new THREE.MeshStandardMaterial({
@@ -926,13 +1056,15 @@ export function ModelViewer({
             metalness: 0.0,
             envMapIntensity: 0.55,
           });
-          const mesh = new THREE.Mesh(stlGeometry!, material);
+          const mesh = new THREE.Mesh(stlGeometry, material);
           mesh.castShadow = true;
           const stlGroup = new THREE.Group();
           stlGroup.add(mesh);
           return stlGroup;
         })()
-      : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
+      : stepMeshes
+        ? buildStepGroup(stepMeshes, filamentColors)
+        : buildModelGroup(parsedData!, selectedPlateId ?? null, filamentColors);
     modelGroupRef.current = group;
     sceneRef.current.add(group);
 
@@ -943,13 +1075,13 @@ export function ModelViewer({
     // Always place models on the build plate (Y=0)
     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)
       : undefined;
-    const selectedPlateOffset = (!isStlModel && selectedPlateId != null)
+    const selectedPlateOffset = (!isPlainModel && selectedPlateId != null)
       ? parsedData!.plateOffsets.get(selectedPlateId)
       : undefined;
-    const shouldCenterOnPlate = isStlModel
+    const shouldCenterOnPlate = isPlainModel
       || parsedData!.buildItems.length === 0
       || (selectedPlateId != null && !selectedPlateBounds && !selectedPlateOffset);
     const centerOffsetX = shouldCenterOnPlate ? -center.x : 0;
@@ -957,7 +1089,7 @@ export function ModelViewer({
 
     let plateOffsetX = 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);
       plateOffsetX = plateBox.min.x - selectedPlateBounds.minX;
       plateOffsetZ = plateBox.min.z - selectedPlateBounds.minY;
@@ -966,10 +1098,10 @@ export function ModelViewer({
     const plateCenterX = buildVolume.x / 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.z = centerOffsetZ - plateOffsetZ;
-    } else if (!isStlModel && selectedPlateId != null && selectedPlateOffset) {
+    } else if (!isPlainModel && selectedPlateId != null && selectedPlateOffset) {
       group.position.x = centerOffsetX + (plateCenterX - selectedPlateOffset.offsetX);
       group.position.z = centerOffsetZ + (plateCenterZ - selectedPlateOffset.offsetY);
     } else if (shouldCenterOnPlate) {
@@ -1003,7 +1135,37 @@ export function ModelViewer({
     fitCameraToBox(cameraRef.current, controlsRef.current, finalBox);
 
     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 = () => {
     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
   // Cog (in-app Bambuddy SliceModal) when the slicer API is enabled.
   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 {
@@ -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 { showToast } = useToast();
   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
       // the 3D-tab + g-code-tab gating (#1543).
       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({
         has_model: hasModel,
         has_source: false,
@@ -772,6 +776,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
                     filamentColors={capabilities.filament_colors}
                     selectedPlateId={selectedPlateId}
                     className="w-full h-full"
+                    onSnapshot={onSnapshot}
                   />
               </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 { 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;
   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;
   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);
 
-  // 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(() => {
+    if (showDeleteConfirm) return;
     const handleKeyDown = (e: KeyboardEvent) => {
       if (e.key === 'Escape') onClose();
       if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
@@ -31,7 +42,7 @@ export function PhotoGalleryModal({
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onClose, photos.length]);
+  }, [onClose, photos.length, showDeleteConfirm]);
 
   // Reset index if photos change
   useEffect(() => {
@@ -45,8 +56,11 @@ export function PhotoGalleryModal({
     return null;
   }
 
+  const resolvePhotoUrl = (filename: string) =>
+    props.getPhotoUrl ? props.getPhotoUrl(filename) : api.getArchivePhotoUrl(props.archiveId, filename);
+
   const currentPhoto = photos[currentIndex];
-  const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
+  const photoUrl = resolvePhotoUrl(currentPhoto);
 
   const handleDownload = () => {
     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 (
     <div
       className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
-      onClick={onClose}
+      onClick={handleBackdropClick}
     >
       <div
         className="relative w-full h-full flex flex-col"
@@ -142,7 +164,7 @@ export function PhotoGalleryModal({
                 }`}
               >
                 <img
-                  src={api.getArchivePhotoUrl(archiveId, photo)}
+                  src={resolvePhotoUrl(photo)}
                   alt={`Thumbnail ${index + 1}`}
                   className="w-full h-full object-cover"
                 />
@@ -152,19 +174,24 @@ export function PhotoGalleryModal({
         )}
       </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 && (
-        <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>
   );

+ 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',
     noPermissionPreview: 'Sie haben keine Berechtigung, Dateien in der Vorschau anzuzeigen',
     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',
     noPermissionGenerateThumbnail: 'Sie haben keine Berechtigung, Vorschaubilder zu generieren',
     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',
     noPermissionPreview: 'You do not have permission to preview files',
     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',
     noPermissionGenerateThumbnail: 'You do not have permission to generate thumbnails',
     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',
     noPermissionPreview: 'No tienes permiso para previsualizar archivos',
     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',
     noPermissionGenerateThumbnail: 'No tiene permiso para generar miniaturas',
     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',
     noPermissionPreview: 'Vous n\'avez pas la permission de prévisualiser les fichiers',
     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',
     noPermissionGenerateThumbnail: 'Pas d\'autorisation vignettes',
     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',
     noPermissionPreview: 'Non hai il permesso di visualizzare l\'anteprima dei file',
     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',
     noPermissionGenerateThumbnail: 'Non hai il permesso di generare miniature',
     noPermissionDeleteFile: 'Non hai il permesso di eliminare questo file',

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

@@ -3995,6 +3995,49 @@ export default {
     noPermissionDownload: 'ファイルをダウンロードする権限がありません',
     noPermissionPreview: 'ファイルをプレビューする権限がありません',
     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: 'このファイル名を変更する権限がありません',
     noPermissionGenerateThumbnail: 'サムネイルを生成する権限がありません',
     noPermissionDeleteFile: 'このファイルを削除する権限がありません',

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

@@ -3805,6 +3805,49 @@ export default {
     noPermissionDownload: '파일 다운로드 권한이 없습니다',
     noPermissionPreview: '파일을 미리 볼 권한이 없습니다',
     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: '파일 이름 변경 권한이 없습니다',
     noPermissionGenerateThumbnail: '썸네일 생성 권한이 없습니다',
     noPermissionDeleteFile: '파일 삭제 권한이 없습니다',

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

@@ -4025,6 +4025,49 @@ export default {
     noPermissionDownload: 'Je hebt geen toestemming om bestanden te downloaden',
     noPermissionPreview: 'Je hebt geen toestemming om bestanden te bekijken',
     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',
     noPermissionGenerateThumbnail: 'Je hebt geen toestemming om miniaturen te genereren',
     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',
     noPermissionPreview: 'Você não tem permissão para pré-visualizar arquivos',
     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',
     noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',
     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: "У вас нет прав на скачивание файлов",
     noPermissionPreview: 'У вас нет прав на предварительный просмотр файлов',
     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: "У вас нет прав на переименование этого файла",
     noPermissionGenerateThumbnail: "У вас нет прав на создание миниатюр",
     noPermissionDeleteFile: "У вас нет прав на удаление этого файла",

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

@@ -4025,6 +4025,49 @@ errors: {
     noPermissionDownload: 'Du har inte behörighet att ladda ner filer',
     noPermissionPreview: 'Du har inte behörighet att förhandsgranska filer',
     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',
     noPermissionGenerateThumbnail: 'Du har inte behörighet att generera minibilder',
     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',
     noPermissionPreview: 'Dosyaları önizleme izniniz yok',
     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',
     noPermissionGenerateThumbnail: 'Küçük resim oluşturma izniniz yok',
     noPermissionDeleteFile: 'Bu dosyayı silme izniniz yok',

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

@@ -4023,6 +4023,49 @@ export default {
     noPermissionDownload: "У вас немає дозволу на завантаження файлів",
     noPermissionPreview: 'У вас немає дозволу на попередній перегляд файлів',
     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: "Ви не маєте дозволу на перейменування цього файлу",
     noPermissionGenerateThumbnail: "Ви не маєте дозволу створювати мініатюри",
     noPermissionDeleteFile: "Ви не маєте дозволу на видалення цього файлу",

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

@@ -3983,6 +3983,49 @@ export default {
     noPermissionDownload: '您没有下载文件的权限',
     noPermissionPreview: '您没有预览文件的权限',
     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: '您没有重命名此文件的权限',
     noPermissionGenerateThumbnail: '您没有生成缩略图的权限',
     noPermissionDeleteFile: '您没有删除此文件的权限',

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

@@ -3983,6 +3983,49 @@ export default {
     noPermissionDownload: '您沒有下載檔案的權限',
     noPermissionPreview: '您沒有預覽檔案的權限',
     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: '您沒有重新命名此檔案的權限',
     noPermissionGenerateThumbnail: '您沒有產生縮圖的權限',
     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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -45,6 +45,12 @@ import {
   Lock,
   FolderSymlink,
   Tag as TagIcon,
+  FileText,
+  FileSpreadsheet,
+  Info,
+  Globe,
+  StickyNote,
+  Camera,
 } from 'lucide-react';
 import { api } from '../api/client';
 import type {
@@ -68,6 +74,7 @@ import { BulkTagsPickerModal } from '../components/BulkTagsPickerModal';
 import { FileUploadModal } from '../components/FileUploadModal';
 import { FolderReadmePanel } from '../components/FolderReadmePanel';
 import { LibraryTagsModal } from '../components/LibraryTagsModal';
+import { LibraryFileDetailsModal } from '../components/LibraryFileDetailsModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { usePageFileDrop } from '../hooks/usePageFileDrop';
@@ -82,6 +89,23 @@ type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 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
 interface NewFolderModalProps {
   parentId: number | null;
@@ -761,7 +785,9 @@ interface FileCardProps {
   desktopSlicer: SlicerType;
   canSlice?: boolean;
   onPreview3d?: (file: LibraryFileListItem) => void;
+  onPreviewDocument?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
+  onDetails?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onTagClick?: (tagId: number) => void;
   thumbnailVersion?: number;
@@ -772,7 +798,7 @@ interface FileCardProps {
   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
   // `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
@@ -814,7 +840,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
       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({
       label: t('fileManager.preview3d'),
       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,
     });
   }
+  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({
     label: t('common.download'),
     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,
     });
   }
+  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') {
     menuItems.push({
       label: t('fileManager.generateThumbnail'),
@@ -874,6 +927,10 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
             alt={file.filename}
             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" />
         )}
@@ -884,6 +941,9 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
           // 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 === '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'
         }`}>
           {file.file_type.toUpperCase()}
@@ -923,6 +983,47 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onPrint, o
             {t('fileManager.printedCount', { count: file.print_count })}
           </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 && (
           <div className="mt-1 text-xs text-bambu-gray flex items-center gap-1">
             <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 [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   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'>(() => {
     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) => {
     api.downloadLibraryFile(id).catch((err) => {
       console.error('Library file download failed:', err);
@@ -2447,7 +2575,12 @@ export function FileManagerPage() {
                         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 })}
+                    onDetails={setDetailsFile}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onTagClick={toggleTagFilter}
                     thumbnailVersion={thumbnailVersions[file.id]}
@@ -2512,7 +2645,13 @@ export function FileManagerPage() {
                             />
                           ) : (
                             <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>
@@ -2530,7 +2669,34 @@ export function FileManagerPage() {
                         )}
                       </div>
                       <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
                             the toolbar. Real on-disk mtime when known, else created_at. */}
                         {showModified && (
@@ -2560,6 +2726,9 @@ export function FileManagerPage() {
                         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 === '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'
                       }`}>
                         {file.file_type.toUpperCase()}
@@ -2642,7 +2811,7 @@ export function FileManagerPage() {
                           <Play className="w-4 h-4" />
                         </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
                           onClick={() => {
                             if (!hasPermission('library:read')) return;
@@ -2657,12 +2826,30 @@ export function FileManagerPage() {
                               ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
                               : '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')}
                         >
                           <Box className="w-4 h-4" />
                         </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
                         onClick={() => hasPermission('library:read') && handleDownload(file.id)}
                         className={`p-1.5 rounded transition-colors ${
@@ -2675,6 +2862,18 @@ export function FileManagerPage() {
                       >
                         <Download className="w-4 h-4" />
                       </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
                         onClick={() => canModify('library', 'update', file.created_by_id) && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
                         className={`p-1.5 rounded transition-colors ${
@@ -2871,6 +3070,9 @@ export function FileManagerPage() {
           title={viewerFile.print_name || viewerFile.filename}
           fileType={viewerFile.file_type}
           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={
             // Only offer in-app slicing on files the SliceModal can actually
             // 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 && (
         <RenameModal
           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',
     emptyOutDir: true,
     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: {
     host: '0.0.0.0',