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;