This is an automated email from the ASF dual-hosted git repository.

aicam pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new a7d2adf243 feat: add custom cover image support for datasets (#4117)
a7d2adf243 is described below

commit a7d2adf243dd6797de7dc3615933fb43d2eceb24
Author: Xuan Gu <[email protected]>
AuthorDate: Mon Jan 12 10:38:08 2026 -0800

    feat: add custom cover image support for datasets (#4117)
    
    <!--
    Thanks for sending a pull request (PR)! Here are some tips for you:
    1. If this is your first time, please read our contributor guidelines:
    [Contributing to
    Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md)
      2. Ensure you have added or run the appropriate tests for your PR
      3. If the PR is work in progress, mark it a draft on GitHub.
      4. Please write your PR title to summarize what this PR proposes, we
        are following Conventional Commits style for PR titles as well.
      5. Be sure to keep the PR description updated to reflect all changes.
    -->
    
    ### What changes were proposed in this PR?
    <!--
    Please clarify what changes you are proposing. The purpose of this
    section
    is to outline the changes. Here are some tips for you:
      1. If you propose a new API, clarify the use case for a new API.
      2. If you fix a bug, you can clarify why it is a bug.
      3. If it is a refactoring, clarify what has been changed.
      3. It would be helpful to include a before-and-after comparison using
         screenshots or GIFs.
      4. Please consider writing useful notes for better and faster reviews.
    -->
    This PR adds support for dataset owners to set a custom cover image from
    files within their dataset. This improves visual presentation and makes
    datasets easier to recognize on the hub/home page.
    
    **Changes:**
    - **Database:**
      - Added a cover_image column to the dataset table.
    - Stores cover image as `{dvid}/{relative_path}` format (e.g.,
    `v1/demo.png`)
    
    - **Backend:**
      - `POST /api/dataset/{did}/update/cover` – Set dataset cover image
    - Validates file type (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`) and
    size (max 10 MB)
    - `GET /api/dataset/{did}/cover` – Get cover image with 307 redirect to
    presigned URL
        - Only accessible for public datasets
    - **Frontend:**
    - Adds a “Set as Cover” button next to image files in the version file
    tree, button only visible for image file (.jpg, .jpeg, .png, .gif,
    .webp)
    <img width="402" height="305" alt="set_icon"
    
src="https://github.com/user-attachments/assets/acb6baad-8fed-4851-b230-e8fb07a02ec8";
    />
    - home page displays cover images on dataset cards with automatic
    fallback to default background
    
    Public datasets display:
    | Before (default cover) | After (customized cover) |
    | --- | --- |
    | <img width="339" height="461" alt="before"
    
src="https://github.com/user-attachments/assets/bad4ee74-8c93-49df-a165-a6abe89e6d94";
    /> | <img width="339" height="461" alt="after"
    
src="https://github.com/user-attachments/assets/ae51fa2d-cc4f-4732-b141-035a7f3d3591";
    /> |
    
    ### Any related issues, documentation, discussions?
    <!--
    Please use this section to link other resources if not mentioned
    already.
    1. If this PR fixes an issue, please include `Fixes #1234`, `Resolves
    #1234`
    or `Closes #1234`. If it is only related, simply mention the issue
    number.
      2. If there is design documentation, please add the link.
      3. If there is a discussion in the mailing list, please add the link.
    -->
    Closes #4115
    
    ### How was this PR tested?
    <!--
    If tests were added, say they were added here. Or simply mention that if
    the PR
    is tested with existing test cases. Make sure to include/update test
    cases that
    check the changes thoroughly including negative and positive cases if
    possible.
    If it was tested in a way different from regular unit tests, please
    clarify how
    you tested step by step, ideally copy and paste-able, so that other
    reviewers can
    test and check, and descendants can verify in the future. If tests were
    not added,
    please describe why they were not added and/or why it was difficult to
    add.
    -->
    Test cases include: path traversal rejection, absolute path rejection,
    invalid file type rejection, empty/null path rejection, unauthorized
    user rejection, private dataset access checks, and validateSafePath
    coverage.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    <!--
    If generative AI tooling has been used in the process of authoring this
    PR,
    please include the phrase: 'Generated-by: ' followed by the name of the
    tool
    and its version. If no, write 'No'.
    Please refer to the [ASF Generative Tooling
    Guidance](https://www.apache.org/legal/generative-tooling.html) for
    details.
    -->
    Generated-by: Claude for code review
    
    ---------
    
    Signed-off-by: Xuan Gu <[email protected]>
---
 .../core/storage/util/LakeFSStorageClient.scala    |  19 ++
 .../texera/service/resource/DatasetResource.scala  | 134 ++++++++++++++-
 .../service/resource/DatasetResourceSpec.scala     | 191 ++++++++++++++++++++-
 frontend/src/app/common/type/dataset.ts            |   1 +
 .../dataset-detail.component.html                  |   3 +-
 .../dataset-detail.component.ts                    |  22 +++
 .../user-dataset-version-creator.component.ts      |   1 +
 .../user-dataset-version-filetree.component.html   |  15 +-
 .../user-dataset-version-filetree.component.scss   |   2 +-
 .../user-dataset-version-filetree.component.ts     |  18 +-
 .../service/user/dataset/dataset.service.ts        |   6 +
 frontend/src/app/dashboard/type/dashboard-entry.ts |   2 +
 .../browse-section/browse-section.component.html   |   3 +-
 .../browse-section/browse-section.component.ts     |  26 +++
 sql/texera_ddl.sql                                 |   1 +
 .../common/type/dataset.ts => sql/updates/18.sql   |  31 ++--
 16 files changed, 446 insertions(+), 29 deletions(-)

diff --git 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala
 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala
index d01e820259..09fa6f3eb3 100644
--- 
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala
+++ 
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/storage/util/LakeFSStorageClient.scala
@@ -402,4 +402,23 @@ object LakeFSStorageClient {
     (bucket, key)
   }
 
+  /**
+    * Get file size.
+    *
+    * @param repoName     Repository name.
+    * @param commitHash   Commit hash of the version.
+    * @param filePath     Path to the file in the repository.
+    * @return File size in bytes
+    */
+  def getFileSize(
+      repoName: String,
+      commitHash: String,
+      filePath: String
+  ): Long = {
+    objectsApi
+      .statObject(repoName, commitHash, filePath)
+      .execute()
+      .getSizeBytes
+      .longValue()
+  }
 }
diff --git 
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
 
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
index 44ce22dfb1..dd53ced373 100644
--- 
a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
+++ 
b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala
@@ -57,7 +57,7 @@ import org.jooq.{DSLContext, EnumType}
 import org.jooq.impl.DSL
 import org.jooq.impl.DSL.{inline => inl}
 import java.io.{InputStream, OutputStream}
-import java.net.{HttpURLConnection, URL, URLDecoder}
+import java.net.{HttpURLConnection, URI, URL, URLDecoder}
 import java.nio.charset.StandardCharsets
 import java.nio.file.{Files, Paths}
 import java.util
@@ -70,6 +70,7 @@ import 
org.apache.texera.dao.jooq.generated.tables.DatasetUploadSession.DATASET_
 import 
org.apache.texera.dao.jooq.generated.tables.DatasetUploadSessionPart.DATASET_UPLOAD_SESSION_PART
 import org.jooq.exception.DataAccessException
 import software.amazon.awssdk.services.s3.model.UploadPartResponse
+import org.apache.commons.io.FilenameUtils
 
 import java.sql.SQLException
 import scala.util.Try
@@ -144,6 +145,25 @@ object DatasetResource {
       .toScala
   }
 
+  /**
+    * Validates a file path using Apache Commons IO.
+    */
+  def validateAndNormalizeFilePathOrThrow(path: String): String = {
+    if (path == null || path.trim.isEmpty) {
+      throw new BadRequestException("Path cannot be empty")
+    }
+
+    val normalized = FilenameUtils.normalize(path, true)
+    if (normalized == null) {
+      throw new BadRequestException("Invalid path")
+    }
+
+    if (FilenameUtils.getPrefixLength(normalized) > 0) {
+      throw new BadRequestException("Absolute paths not allowed")
+    }
+    normalized
+  }
+
   case class DashboardDataset(
       dataset: Dataset,
       ownerEmail: String,
@@ -177,6 +197,8 @@ object DatasetResource {
       fileNodes: List[DatasetFileNode],
       size: Long
   )
+
+  case class CoverImageRequest(coverImage: String)
 }
 
 @Produces(Array(MediaType.APPLICATION_JSON, "image/jpeg", "application/pdf"))
@@ -186,6 +208,9 @@ class DatasetResource {
   private val ERR_DATASET_VERSION_NOT_FOUND_MESSAGE = "The version of the 
dataset not found"
   private val EXPIRATION_MINUTES = 5
 
+  private val COVER_IMAGE_SIZE_LIMIT_BYTES: Long = 10 * 1024 * 1024 // 10 MB
+  private val ALLOWED_IMAGE_EXTENSIONS: Set[String] = Set(".jpg", ".jpeg", 
".png", ".gif", ".webp")
+
   /**
     * Helper function to get the dataset from DB with additional information 
including user access privilege and owner email
     */
@@ -1742,4 +1767,111 @@ class DatasetResource {
       Response.ok(Map("message" -> "Multipart upload aborted 
successfully")).build()
     }
   }
+
+  /**
+    * Updates the cover image for a dataset.
+    *
+    * @param did Dataset ID
+    * @param request Cover image request containing the relative file path
+    * @param sessionUser Authenticated user session
+    * @return Response with updated cover image path
+    *
+    * Expected coverImage format: "version/folder/image.jpg" (relative to 
dataset root)
+    */
+  @POST
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
+  @Path("/{did}/update/cover")
+  @Consumes(Array(MediaType.APPLICATION_JSON))
+  def updateDatasetCoverImage(
+      @PathParam("did") did: Integer,
+      request: CoverImageRequest,
+      @Auth sessionUser: SessionUser
+  ): Response = {
+    withTransaction(context) { ctx =>
+      val uid = sessionUser.getUid
+      val dataset = getDatasetByID(ctx, did)
+      if (!userHasWriteAccess(ctx, did, uid)) {
+        throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+      }
+
+      if (request.coverImage == null || request.coverImage.trim.isEmpty) {
+        throw new BadRequestException("Cover image path is required")
+      }
+
+      val normalized = 
DatasetResource.validateAndNormalizeFilePathOrThrow(request.coverImage)
+
+      val extension = FilenameUtils.getExtension(normalized)
+      if (extension == null || 
!ALLOWED_IMAGE_EXTENSIONS.contains(s".$extension".toLowerCase)) {
+        throw new BadRequestException("Invalid file type")
+      }
+
+      val owner = getOwner(ctx, did)
+      val document = DocumentFactory
+        .openReadonlyDocument(
+          
FileResolver.resolve(s"${owner.getEmail}/${dataset.getName}/$normalized")
+        )
+        .asInstanceOf[OnDataset]
+
+      val fileSize = LakeFSStorageClient.getFileSize(
+        document.getRepositoryName(),
+        document.getVersionHash(),
+        document.getFileRelativePath()
+      )
+
+      if (fileSize > COVER_IMAGE_SIZE_LIMIT_BYTES) {
+        throw new BadRequestException(
+          s"Cover image must be less than ${COVER_IMAGE_SIZE_LIMIT_BYTES / 
(1024 * 1024)} MB"
+        )
+      }
+
+      dataset.setCoverImage(normalized)
+      new DatasetDao(ctx.configuration()).update(dataset)
+      Response.ok(Map("coverImage" -> normalized)).build()
+    }
+  }
+
+  /**
+    * Get the cover image for a dataset.
+    * Returns a 307 redirect to the presigned S3 URL.
+    *
+    * @param did Dataset ID
+    * @return 307 Temporary Redirect to cover image
+    */
+  @GET
+  @Path("/{did}/cover")
+  def getDatasetCover(
+      @PathParam("did") did: Integer,
+      @Auth sessionUser: Optional[SessionUser]
+  ): Response = {
+    withTransaction(context) { ctx =>
+      val dataset = getDatasetByID(ctx, did)
+
+      val requesterUid = if (sessionUser.isPresent) 
Some(sessionUser.get().getUid) else None
+
+      if (requesterUid.isEmpty && !dataset.getIsPublic) {
+        throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+      } else if (requesterUid.exists(uid => !userHasReadAccess(ctx, did, 
uid))) {
+        throw new ForbiddenException(ERR_USER_HAS_NO_ACCESS_TO_DATASET_MESSAGE)
+      }
+
+      val coverImage = Option(dataset.getCoverImage).getOrElse(
+        throw new NotFoundException("No cover image")
+      )
+
+      val owner = getOwner(ctx, did)
+      val fullPath = s"${owner.getEmail}/${dataset.getName}/$coverImage"
+
+      val document = DocumentFactory
+        .openReadonlyDocument(FileResolver.resolve(fullPath))
+        .asInstanceOf[OnDataset]
+
+      val presignedUrl = LakeFSStorageClient.getFilePresignedUrl(
+        document.getRepositoryName(),
+        document.getVersionHash(),
+        document.getFileRelativePath()
+      )
+
+      Response.temporaryRedirect(new URI(presignedUrl)).build()
+    }
+  }
 }
diff --git 
a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
 
b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
index 3f72c57486..8a6ee34f5f 100644
--- 
a/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
+++ 
b/file-service/src/test/scala/org/apache/texera/service/resource/DatasetResourceSpec.scala
@@ -29,8 +29,8 @@ import org.apache.texera.dao.MockTexeraDB
 import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum}
 import 
org.apache.texera.dao.jooq.generated.tables.DatasetUploadSession.DATASET_UPLOAD_SESSION
 import 
org.apache.texera.dao.jooq.generated.tables.DatasetUploadSessionPart.DATASET_UPLOAD_SESSION_PART
-import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, UserDao}
-import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, User}
+import org.apache.texera.dao.jooq.generated.tables.daos.{DatasetDao, 
DatasetVersionDao, UserDao}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{Dataset, 
DatasetVersion, User}
 import org.apache.texera.service.MockLakeFS
 import org.jooq.SQLDialect
 import org.jooq.impl.DSL
@@ -132,6 +132,33 @@ class DatasetResourceSpec
     dataset
   }
 
+  // Test fixtures for cover image tests. Creates file in LakeFS and 
DatasetVersion record.
+  private val testCoverImagePath = "v1/test-cover.jpg"
+  private val testImageBytes: Array[Byte] = Array.fill[Byte](1024)(0xff.toByte)
+
+  private lazy val testDatasetVersion: DatasetVersion = {
+    try {
+      LakeFSStorageClient.initRepo(baseDataset.getRepositoryName)
+    } catch {
+      case e: ApiException if e.getCode == 409 =>
+    }
+
+    LakeFSStorageClient.writeFileToRepo(
+      baseDataset.getRepositoryName,
+      "test-cover.jpg",
+      new ByteArrayInputStream(testImageBytes)
+    )
+
+    val version = new DatasetVersion()
+    version.setDid(baseDataset.getDid)
+    version.setCreatorUid(ownerUser.getUid)
+    version.setName("v1")
+    version.setVersionHash("main")
+
+    new DatasetVersionDao(getDSLContext.configuration()).insert(version)
+    version
+  }
+
   // ---------- DAOs / resource ----------
   lazy val datasetDao = new DatasetDao(getDSLContext.configuration())
   lazy val datasetResource = new DatasetResource()
@@ -1328,4 +1355,164 @@ class DatasetResourceSpec
     val part1 = fetchPartRows(uploadId).find(_.getPartNumber == 1).get
     part1.getEtag.trim should not be ""
   }
+
+  // 
===========================================================================
+  // Cover Image Tests
+  // 
===========================================================================
+
+  "updateDatasetCoverImage" should "reject path traversal attempts" in {
+    val maliciousPaths = Seq(
+      "../../../etc/passwd",
+      "v1/../../secret.txt",
+      "../escape.jpg"
+    )
+
+    maliciousPaths.foreach { path =>
+      val request = DatasetResource.CoverImageRequest(path)
+
+      assertThrows[BadRequestException] {
+        datasetResource.updateDatasetCoverImage(
+          baseDataset.getDid,
+          request,
+          sessionUser
+        )
+      }
+    }
+  }
+
+  it should "reject absolute paths" in {
+    val absolutePaths = Seq(
+      "/etc/passwd",
+      "/var/log/system.log"
+    )
+
+    absolutePaths.foreach { path =>
+      val request = DatasetResource.CoverImageRequest(path)
+
+      assertThrows[BadRequestException] {
+        datasetResource.updateDatasetCoverImage(
+          baseDataset.getDid,
+          request,
+          sessionUser
+        )
+      }
+    }
+  }
+
+  it should "reject invalid file types" in {
+    val invalidPaths = Seq(
+      "v1/script.js",
+      "v1/document.pdf",
+      "v1/data.csv"
+    )
+
+    invalidPaths.foreach { path =>
+      val request = DatasetResource.CoverImageRequest(path)
+
+      assertThrows[BadRequestException] {
+        datasetResource.updateDatasetCoverImage(
+          baseDataset.getDid,
+          request,
+          sessionUser
+        )
+      }
+    }
+  }
+
+  it should "reject empty or null cover image path" in {
+    assertThrows[BadRequestException] {
+      datasetResource.updateDatasetCoverImage(
+        baseDataset.getDid,
+        DatasetResource.CoverImageRequest(""),
+        sessionUser
+      )
+    }
+
+    assertThrows[BadRequestException] {
+      datasetResource.updateDatasetCoverImage(
+        baseDataset.getDid,
+        DatasetResource.CoverImageRequest(null),
+        sessionUser
+      )
+    }
+  }
+
+  it should "reject when user lacks WRITE access" in {
+    val request = DatasetResource.CoverImageRequest("v1/cover.jpg")
+
+    assertThrows[ForbiddenException] {
+      datasetResource.updateDatasetCoverImage(
+        baseDataset.getDid,
+        request,
+        sessionUser2
+      )
+    }
+  }
+
+  it should "set cover image successfully" in {
+    testDatasetVersion
+
+    val request = DatasetResource.CoverImageRequest(testCoverImagePath)
+    val response = datasetResource.updateDatasetCoverImage(
+      baseDataset.getDid,
+      request,
+      sessionUser
+    )
+
+    response.getStatus shouldEqual 200
+
+    val updated = datasetDao.fetchOneByDid(baseDataset.getDid)
+    updated.getCoverImage shouldEqual testCoverImagePath
+  }
+
+  "getDatasetCover" should "reject private dataset cover for anonymous users" 
in {
+    val dataset = datasetDao.fetchOneByDid(baseDataset.getDid)
+    dataset.setIsPublic(false)
+    dataset.setCoverImage("v1/cover.jpg")
+    datasetDao.update(dataset)
+
+    assertThrows[ForbiddenException] {
+      datasetResource.getDatasetCover(baseDataset.getDid, Optional.empty())
+    }
+  }
+
+  it should "reject private dataset cover for users without access" in {
+    val dataset = datasetDao.fetchOneByDid(baseDataset.getDid)
+    dataset.setOwnerUid(ownerUser.getUid)
+    dataset.setIsPublic(false)
+    dataset.setCoverImage("v1/cover.jpg")
+    datasetDao.update(dataset)
+
+    assertThrows[ForbiddenException] {
+      datasetResource.getDatasetCover(baseDataset.getDid, 
Optional.of(sessionUser2))
+    }
+  }
+
+  it should "return 404 when no cover image is set" in {
+    val dataset = datasetDao.fetchOneByDid(baseDataset.getDid)
+    dataset.setCoverImage(null)
+    dataset.setIsPublic(true)
+    datasetDao.update(dataset)
+
+    assertThrows[NotFoundException] {
+      datasetResource.getDatasetCover(baseDataset.getDid, 
Optional.of(sessionUser))
+    }
+  }
+
+  it should "get cover image successfully with 307 redirect" in {
+    testDatasetVersion
+
+    val dataset = datasetDao.fetchOneByDid(baseDataset.getDid)
+    dataset.setIsPublic(true)
+    dataset.setCoverImage(testCoverImagePath)
+    datasetDao.update(dataset)
+
+    val response = datasetResource.getDatasetCover(
+      baseDataset.getDid,
+      Optional.empty()
+    )
+
+    response.getStatus shouldEqual 307
+    response.getHeaderString("Location") should not be null
+  }
 }
diff --git a/frontend/src/app/common/type/dataset.ts 
b/frontend/src/app/common/type/dataset.ts
index 7825ca2797..97ff370302 100644
--- a/frontend/src/app/common/type/dataset.ts
+++ b/frontend/src/app/common/type/dataset.ts
@@ -38,4 +38,5 @@ export interface Dataset {
   storagePath: string | undefined;
   description: string;
   creationTime: number | undefined;
+  coverImage: string | undefined;
 }
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
index d4dddf94f6..79ced02f86 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html
@@ -263,7 +263,8 @@
             [fileTreeNodes]="fileTreeNodeList"
             [isTreeNodeDeletable]="true"
             (selectedTreeNode)="onVersionFileTreeNodeSelected($event)"
-            (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)">
+            (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)"
+            (setCoverImage)="onSetCoverImage($event)">
           </texera-user-dataset-version-filetree>
         </nz-collapse-panel>
       </nz-collapse>
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
index bfc97379ec..53a3c67391 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts
@@ -696,4 +696,26 @@ export class DatasetDetailComponent implements OnInit {
   changeViewDisplayStyle() {
     this.displayPreciseViewCount = !this.displayPreciseViewCount;
   }
+
+  onSetCoverImage(filePath: string): void {
+    if (!this.did || !this.selectedVersion) {
+      return;
+    }
+
+    this.datasetService
+      .updateDatasetCoverImage(this.did, 
`${this.selectedVersion.name}/${filePath}`)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: () => {
+          this.notificationService.success("Cover image set successfully");
+        },
+        error: (err: unknown) => {
+          this.notificationService.error(
+            err instanceof HttpErrorResponse
+              ? err.error?.message || "Failed to set cover image"
+              : "Failed to set cover image"
+          );
+        },
+      });
+  }
 }
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts
index c1f9cffc35..1d59e851e3 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-creator/user-dataset-version-creator.component.ts
@@ -174,6 +174,7 @@ export class UserDatasetVersionCreatorComponent implements 
OnInit {
         ownerUid: undefined,
         storagePath: undefined,
         creationTime: undefined,
+        coverImage: undefined,
       };
       this.datasetService
         .createDataset(ds)
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
index f09cdb37d4..ee74c29441 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.html
@@ -41,13 +41,26 @@
           nz-button
           nzType="link"
           *ngIf="isTreeNodeDeletable && !node.data.children"
-          class="delete-button"
+          class="icon-button"
           (click)="onNodeDeleted(node.data)">
           <i
             nz-icon
             nzType="delete"
             nzTheme="outline"></i>
         </button>
+
+        <button
+          nz-button
+          nzType="link"
+          *ngIf="!node.data.children && isImageFile(node.data.name)"
+          class="icon-button"
+          nz-tooltip="Set as cover"
+          (click)="onSetCover(node.data)">
+          <i
+            nz-icon
+            nzType="picture"
+            nzTheme="outline"></i>
+        </button>
       </span>
     </ng-template>
   </tree-root>
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss
index edfaab6128..54cbcd44af 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.scss
@@ -22,7 +22,7 @@
 }
 
 /* Styles for the delete button */
-.delete-button {
+.icon-button {
   width: 15px;
   margin-left: 5px;
 }
diff --git 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
index f3e3e67e1a..c920303474 100644
--- 
a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
+++ 
b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component.ts
@@ -19,9 +19,14 @@
 
 import { UntilDestroy } from "@ngneat/until-destroy";
 import { AfterViewInit, Component, EventEmitter, Input, Output, ViewChild } 
from "@angular/core";
-import { DatasetFileNode } from 
"../../../../../../common/type/datasetVersionFileTree";
+import {
+  DatasetFileNode,
+  getRelativePathFromDatasetFileNode,
+} from "../../../../../../common/type/datasetVersionFileTree";
 import { ITreeOptions, TREE_ACTIONS } from "@ali-hm/angular-tree-component";
 
+const IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".webp"] as const;
+
 @UntilDestroy()
 @Component({
   selector: "texera-user-dataset-version-filetree",
@@ -40,6 +45,9 @@ export class UserDatasetVersionFiletreeComponent implements 
AfterViewInit {
 
   @ViewChild("tree") tree: any;
 
+  @Output()
+  setCoverImage = new EventEmitter<string>();
+
   public fileTreeDisplayOptions: ITreeOptions = {
     displayField: "name",
     hasChildrenField: "children",
@@ -74,4 +82,12 @@ export class UserDatasetVersionFiletreeComponent implements 
AfterViewInit {
       this.tree.treeModel.expandAll();
     }
   }
+
+  isImageFile(fileName: string): boolean {
+    return IMAGE_EXTENSIONS.some(ext => fileName.toLowerCase().endsWith(ext));
+  }
+
+  onSetCover(nodeData: DatasetFileNode): void {
+    this.setCoverImage.emit(getRelativePathFromDatasetFileNode(nodeData));
+  }
 }
diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts 
b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts
index 97b2e264b7..64c6cb0b36 100644
--- a/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts
+++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.ts
@@ -559,4 +559,10 @@ export class DatasetService {
   public retrieveOwners(): Observable<string[]> {
     return 
this.http.get<string[]>(`${AppSettings.getApiEndpoint()}/${DATASET_GET_OWNERS_URL}`);
   }
+
+  public updateDatasetCoverImage(did: number, coverImage: string): 
Observable<Response> {
+    return 
this.http.post<Response>(`${AppSettings.getApiEndpoint()}/dataset/${did}/update/cover`,
 {
+      coverImage: coverImage,
+    });
+  }
 }
diff --git a/frontend/src/app/dashboard/type/dashboard-entry.ts 
b/frontend/src/app/dashboard/type/dashboard-entry.ts
index e526ea01ba..6dfb46cc1c 100644
--- a/frontend/src/app/dashboard/type/dashboard-entry.ts
+++ b/frontend/src/app/dashboard/type/dashboard-entry.ts
@@ -48,6 +48,7 @@ export class DashboardEntry {
   likeCount: number;
   isLiked: boolean;
   accessibleUserIds: number[];
+  coverImageUrl?: string;
 
   constructor(public value: DashboardWorkflow | DashboardProject | 
DashboardFile | DashboardDataset) {
     if (isDashboardWorkflow(value)) {
@@ -122,6 +123,7 @@ export class DashboardEntry {
       this.likeCount = 0;
       this.isLiked = false;
       this.accessibleUserIds = [];
+      this.coverImageUrl = value.dataset.coverImage;
     } else {
       throw new Error("Unexpected type in DashboardEntry.");
     }
diff --git 
a/frontend/src/app/hub/component/browse-section/browse-section.component.html 
b/frontend/src/app/hub/component/browse-section/browse-section.component.html
index 3d7080e0eb..2fd8f37525 100644
--- 
a/frontend/src/app/hub/component/browse-section/browse-section.component.html
+++ 
b/frontend/src/app/hub/component/browse-section/browse-section.component.html
@@ -44,7 +44,8 @@
           <img
             alt="example"
             class="card-cover-image"
-            [src]="defaultBackground" />
+            [src]="getCoverImage(entity)"
+            (error)="$any($event.target).src = defaultBackground" />
           <nz-avatar
             class="entity-avatar"
             [ngStyle]="{ 'background-color': 'grey', 'vertical-align': 
'middle' }"
diff --git 
a/frontend/src/app/hub/component/browse-section/browse-section.component.ts 
b/frontend/src/app/hub/component/browse-section/browse-section.component.ts
index 4527915f6c..4e97ff4af8 100644
--- a/frontend/src/app/hub/component/browse-section/browse-section.component.ts
+++ b/frontend/src/app/hub/component/browse-section/browse-section.component.ts
@@ -28,6 +28,7 @@ import {
   DASHBOARD_USER_DATASET,
   DASHBOARD_USER_WORKSPACE,
 } from "../../../app-routing.constant";
+import { AppSettings } from "../../../common/app-setting";
 
 @UntilDestroy()
 @Component({
@@ -47,6 +48,8 @@ export class BrowseSectionComponent implements OnInit, 
OnChanges {
   protected readonly DASHBOARD_USER_DATASET = DASHBOARD_USER_DATASET;
   entityRoutes: { [key: number]: string[] } = {};
 
+  private coverImageUrls = new Map<number, string>();
+
   constructor(
     private workflowPersistService: WorkflowPersistService,
     private datasetService: DatasetService,
@@ -57,12 +60,14 @@ export class BrowseSectionComponent implements OnInit, 
OnChanges {
     this.entities.forEach(entity => {
       this.initializeEntry(entity);
     });
+    this.loadCoverImages();
   }
 
   ngOnChanges(changes: SimpleChanges): void {
     this.entities.forEach(entity => {
       this.initializeEntry(entity);
     });
+    this.loadCoverImages();
   }
 
   private initializeEntry(entity: DashboardEntry): void {
@@ -89,4 +94,25 @@ export class BrowseSectionComponent implements OnInit, 
OnChanges {
       throw new Error("Unexpected type in DashboardEntry.");
     }
   }
+
+  private loadCoverImages(): void {
+    if (!this.entities) return;
+
+    this.entities
+      .filter(
+        (entity): entity is DashboardEntry & { id: number } =>
+          entity.type === "dataset" &&
+          entity.coverImageUrl !== undefined &&
+          entity.id !== undefined &&
+          !this.coverImageUrls.has(entity.id)
+      )
+      .forEach(entity => {
+        const coverUrl = 
`${AppSettings.getApiEndpoint()}/dataset/${entity.id}/cover`;
+        this.coverImageUrls.set(entity.id, coverUrl);
+      });
+  }
+
+  getCoverImage(entity: DashboardEntry): string {
+    return this.coverImageUrls.get(entity.id!) || this.defaultBackground;
+  }
 }
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index 57ac69b687..07206afc30 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -252,6 +252,7 @@ CREATE TABLE IF NOT EXISTS dataset
     is_downloadable BOOLEAN NOT NULL DEFAULT TRUE,
     description    VARCHAR(512) NOT NULL,
     creation_time  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    cover_image    varchar(255),
     FOREIGN KEY (owner_uid) REFERENCES "user"(uid) ON DELETE CASCADE
     );
 
diff --git a/frontend/src/app/common/type/dataset.ts b/sql/updates/18.sql
similarity index 59%
copy from frontend/src/app/common/type/dataset.ts
copy to sql/updates/18.sql
index 7825ca2797..501bbae8e5 100644
--- a/frontend/src/app/common/type/dataset.ts
+++ b/sql/updates/18.sql
@@ -1,4 +1,4 @@
-/**
+/*
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
  * distributed with this work for additional information
@@ -17,25 +17,14 @@
  * under the License.
  */
 
-import { DatasetFileNode } from "./datasetVersionFileTree";
+\c texera_db
 
-export interface DatasetVersion {
-  dvid: number | undefined;
-  did: number;
-  creatorUid: number;
-  name: string;
-  versionHash: string | undefined;
-  creationTime: number | undefined;
-  fileNodes: DatasetFileNode[] | undefined;
-}
+SET search_path TO texera_db;
 
-export interface Dataset {
-  did: number | undefined;
-  ownerUid: number | undefined;
-  name: string;
-  isPublic: boolean;
-  isDownloadable: boolean;
-  storagePath: string | undefined;
-  description: string;
-  creationTime: number | undefined;
-}
+BEGIN;
+
+-- 1. Add new column cover_image to dataset table.
+ALTER TABLE dataset
+    ADD COLUMN cover_image varchar(246);
+
+COMMIT;


Reply via email to