diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html deleted file mode 100644 index 205e3848f32..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - {{ entry.name }} - - - - - - - - - {{ entry.ownerName || entry.ownerEmail || 'Unknown' }} - - - ยท - Created {{ formatRelativeTime(entry.lastModifiedTime) }} - - - - - - - {{ entry.size ? formatSize(entry.size) : '-' }} - - - - {{ formatCount(viewCount) }} - - - - - {{ formatCount(likeCount) }} - - - - - - - - - - #{{ entry.id }} - - diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss deleted file mode 100644 index 60f4e159133..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss +++ /dev/null @@ -1,198 +0,0 @@ -/** - * 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 - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -.dataset-card { - height: 100%; - display: flex; - flex-direction: column; - border-radius: 8px; - overflow: hidden; - cursor: pointer; -} - -.dataset-card-body-link { - display: flex; - flex: 1; - flex-direction: column; - min-height: 0; - color: inherit; -} - -.cover-container { - position: relative; - height: 124px; - background: #f5f5f5; - overflow: hidden; - - .cover-image { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - - .cover-id-badge { - position: absolute; - left: 8px; - bottom: 8px; - padding: 2px 8px; - font-size: 12px; - border-radius: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - background: rgba(15, 14, 12, 0.72); - color: white; - } -} - -.card-title { - display: -webkit-box; - height: calc(15px * 1.35 * 2); - margin-bottom: 10px; - font-size: 15px; - font-weight: 600; - line-height: 1.35; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - word-break: break-word; -} - -.truncate-single-line { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.card-meta { - display: flex; - flex-direction: column; - gap: 5px; - margin-top: auto; - padding-top: 4px; - min-width: 0; - - .meta-line { - display: flex; - align-items: center; - gap: 6px; - color: #595959; - min-width: 0; - - &--owner { - font-size: 13px; - - .meta-owner { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - } - - .meta-avatar { - flex-shrink: 0; - - ::ng-deep nz-avatar.ant-avatar { - width: 20px; - height: 20px; - line-height: 20px; - font-size: 10px; - } - ::ng-deep .owner-badge { - font-size: 9px; - } - } - - .meta-dot { - flex-shrink: 0; - color: #bfbfbf; - font-size: 13px; - line-height: 1; - user-select: none; - } - - .meta-updated { - flex-shrink: 0; - font-size: 12px; - color: #8c8c8c; - white-space: nowrap; - } - } - - &--stats { - justify-content: space-between; - font-size: 12px; - color: #8c8c8c; - } - - .meta-stat { - display: inline-flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - white-space: nowrap; - - i { - font-size: 12px; - } - } - - .meta-stat--like { - padding: 0 10px; - border: 1px solid #e8e8e8; - border-radius: 999px; - background: transparent; - color: inherit; - font-size: 12px; - gap: 8px; - cursor: pointer; - transition: border-color 0.15s; - - i { - font-size: 11px; - transition: color 0.15s; - } - - &.liked i, - &:not(.disabled):not(.liked):hover i { - color: #e0506e; - } - - &:not(.disabled):hover { - border-color: #e0506e; - } - - &.disabled { - cursor: default; - } - } - } - - .meta-stats-left { - display: inline-flex; - align-items: center; - gap: 12px; - min-width: 0; - } - - .meta-hr { - height: 1px; - background: #f0f0f0; - margin: 2px 0; - } -} diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts deleted file mode 100644 index 1ca8248441c..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * 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 - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { HttpClientTestingModule } from "@angular/common/http/testing"; -import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; -import { RouterTestingModule } from "@angular/router/testing"; -import { of, throwError } from "rxjs"; -import type { Mocked } from "vitest"; - -import { DatasetCardItemComponent } from "./dataset-card-item.component"; -import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; -import { HubService } from "../../../../hub/service/hub.service"; -import { UserService } from "../../../../common/service/user/user.service"; -import { StubUserService } from "../../../../common/service/user/stub-user.service"; -import { HUB_DATASET_RESULT_DETAIL, USER_DATASET } from "../../../../app-routing.constant"; -import { commonTestProviders } from "../../../../common/testing/test-utils"; - -function makeDatasetEntry(overrides: Partial = {}): DashboardEntry { - return { - type: "dataset", - id: 42, - accessibleUserIds: [1, 2], - coverImageUrl: undefined, - likeCount: 5, - isLiked: false, - ...overrides, - } as unknown as DashboardEntry; -} - -describe("DatasetCardItemComponent", () => { - let component: DatasetCardItemComponent; - let fixture: ComponentFixture; - let hubService: Mocked; - - beforeEach(async () => { - const hubServiceSpy = { - toggleLike: vi.fn().mockReturnValue(of({ liked: true, likeCount: 7 })), - }; - - await TestBed.configureTestingModule({ - imports: [DatasetCardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], - providers: [ - { - provide: DatasetService, - useValue: { - getDatasetCoverUrl: vi.fn().mockReturnValue(of({ url: "https://s3.example/presigned" })), - }, - }, - { provide: HubService, useValue: hubServiceSpy }, - { provide: UserService, useClass: StubUserService }, - ...commonTestProviders, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(DatasetCardItemComponent); - component = fixture.componentInstance; - hubService = TestBed.inject(HubService) as unknown as Mocked; - }); - - describe("entryLink", () => { - it("routes to the private dataset page when the current user has access", () => { - component.currentUid = 1; - component.entry = makeDatasetEntry({ id: 99, accessibleUserIds: [1, 2] }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.entryLink).toEqual([USER_DATASET, "99"]); - }); - - it("routes to the hub detail page when the current user has no access", () => { - component.currentUid = 5; - component.entry = makeDatasetEntry({ id: 99, accessibleUserIds: [1, 2] }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.entryLink).toEqual([HUB_DATASET_RESULT_DETAIL, "99"]); - }); - }); - - describe("coverImageSrc", () => { - it("falls back to the default cover when coverImageUrl is missing", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - component.entry = makeDatasetEntry({ coverImageUrl: undefined }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.coverImageSrc).toBe(component.defaultCover); - expect(datasetService.getDatasetCoverUrl).not.toHaveBeenCalled(); - }); - - it("swaps in the presigned URL once the backend resolves it", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - component.entry = makeDatasetEntry({ id: 7, coverImageUrl: "v1/img.png" }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(datasetService.getDatasetCoverUrl).toHaveBeenCalledWith(7); - expect(component.coverImageSrc).toBe("https://s3.example/presigned"); - }); - - it("falls back to the default cover when the backend returns a null url", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - datasetService.getDatasetCoverUrl.mockReturnValueOnce(of({ url: null })); - component.entry = makeDatasetEntry({ id: 9, coverImageUrl: "v1/img.png" }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.coverImageSrc).toBe(component.defaultCover); - }); - - it("falls back to the default cover when the backend errors", () => { - const datasetService = TestBed.inject(DatasetService) as unknown as Mocked; - datasetService.getDatasetCoverUrl.mockReturnValueOnce(throwError(() => new Error("403"))); - component.entry = makeDatasetEntry({ id: 11, coverImageUrl: "v1/img.png" }); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - expect(component.coverImageSrc).toBe(component.defaultCover); - }); - }); - - describe("toggleLike", () => { - beforeEach(() => { - component.currentUid = 1; - component.entry = makeDatasetEntry(); - component.ngOnChanges({ entry: { currentValue: component.entry } } as any); - }); - - it("does nothing when the user is not signed in", () => { - component.currentUid = undefined; - component.toggleLike(); - expect(hubService.toggleLike).not.toHaveBeenCalled(); - }); - - it("toggles to liked and reconciles state from the server", () => { - component.isLiked = false; - component.toggleLike(); - expect(hubService.toggleLike).toHaveBeenCalledWith(42, "dataset", false); - expect(component.isLiked).toBe(true); - expect(component.likeCount).toBe(7); - }); - - it("toggles to unliked and reconciles state from the server", () => { - hubService.toggleLike.mockReturnValueOnce(of({ liked: false, likeCount: 6 })); - component.isLiked = true; - component.toggleLike(); - expect(hubService.toggleLike).toHaveBeenCalledWith(42, "dataset", true); - expect(component.isLiked).toBe(false); - expect(component.likeCount).toBe(6); - }); - }); -}); diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts deleted file mode 100644 index 5e9fd3e27f1..00000000000 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * 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 - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges } from "@angular/core"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { RouterLink } from "@angular/router"; -import { NzCardComponent } from "ng-zorro-antd/card"; -import { NzIconDirective } from "ng-zorro-antd/icon"; -import { DashboardEntry } from "../../../type/dashboard-entry"; -import { UserAvatarComponent } from "../user-avatar/user-avatar.component"; -import { DatasetService } from "../../../service/user/dataset/dataset.service"; -import { HubService } from "../../../../hub/service/hub.service"; -import { formatSize } from "../../../../common/util/size-formatter.util"; -import { formatCount, formatRelativeTime } from "../../../../common/util/format.util"; -import { isDefined } from "../../../../common/util/predicate"; -import { HUB_DATASET_RESULT_DETAIL, USER_DATASET } from "../../../../app-routing.constant"; - -@UntilDestroy() -@Component({ - selector: "texera-dataset-card-item", - templateUrl: "./dataset-card-item.component.html", - styleUrls: ["./dataset-card-item.component.scss"], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, NzCardComponent, NzIconDirective, UserAvatarComponent], -}) -export class DatasetCardItemComponent implements OnChanges { - @Input() currentUid: number | undefined; - @Input() entry!: DashboardEntry; - - entryLink: string[] = []; - coverImageSrc: string = ""; - readonly defaultCover = "assets/card_background.jpg"; - likeCount = 0; - viewCount = 0; - isLiked = false; - - constructor( - private datasetService: DatasetService, - private hubService: HubService, - private cdr: ChangeDetectorRef - ) {} - - ngOnChanges(changes: SimpleChanges): void { - if (changes["entry"] || changes["currentUid"]) { - this.initializeEntry(); - } - if (changes["entry"]) { - this.likeCount = this.entry.likeCount ?? 0; - this.viewCount = this.entry.viewCount ?? 0; - this.isLiked = this.entry.isLiked ?? false; - } - } - - private initializeEntry(): void { - if (!this.entry || this.entry.type !== "dataset" || typeof this.entry.id !== "number") { - return; - } - const did = this.entry.id; - const owners = this.entry.accessibleUserIds; - if (this.currentUid !== undefined && owners.includes(this.currentUid)) { - this.entryLink = [USER_DATASET, String(did)]; - } else { - this.entryLink = [HUB_DATASET_RESULT_DETAIL, String(did)]; - } - - this.coverImageSrc = this.defaultCover; - if (this.entry.coverImageUrl) { - this.datasetService - .getDatasetCoverUrl(did) - .pipe(untilDestroyed(this)) - .subscribe({ - next: ({ url }) => { - this.coverImageSrc = url ?? this.defaultCover; - this.cdr.markForCheck(); - }, - error: () => { - this.coverImageSrc = this.defaultCover; - this.cdr.markForCheck(); - }, - }); - } - } - - onCoverError(event: Event): void { - const image = event.target as HTMLImageElement; - image.onerror = null; - image.src = this.defaultCover; - } - - toggleLike(): void { - if (!isDefined(this.currentUid) || !isDefined(this.entry.id)) return; - // optimistic flip; server response reconciles or reverts - const previousLiked = this.isLiked; - this.isLiked = !previousLiked; - this.likeCount += previousLiked ? -1 : 1; - this.cdr.markForCheck(); - - this.hubService - .toggleLike(this.entry.id, this.entry.type, previousLiked) - .pipe(untilDestroyed(this)) - .subscribe({ - next: ({ liked, likeCount }) => { - this.isLiked = liked; - this.likeCount = likeCount; - this.cdr.markForCheck(); - }, - error: () => { - this.isLiked = previousLiked; - this.likeCount += previousLiked ? 1 : -1; - this.cdr.markForCheck(); - }, - }); - } - - formatSize = formatSize; - formatCount = formatCount; - formatRelativeTime = formatRelativeTime; -} diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html index 43b965f5bcc..7b6e1b85d2c 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html @@ -40,6 +40,7 @@ @@ -152,7 +153,7 @@ (click)="toggleLike(); $event.stopPropagation()"> {{ formatCount(this.likeCount) }} diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss index 92a8ab7c6cc..273dbb938be 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss @@ -16,10 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -// Styled to match the dataset card view (dataset-card-item.component.scss) for -// a consistent card look across the dashboard: 8px radius, 124px cover, a +// A consistent card look across the dashboard: 8px radius, 124px cover, a // muted #8c8c8c / #595959 meta palette, #f0f0f0 dividers and a #e0506e like -// accent. Markup/structure intentionally unchanged. +// accent. .card-item { width: 100%; @@ -206,9 +205,19 @@ } .like-btn { + margin-left: auto; + padding: 0 12px; + border: 1px solid #e8e8e8; + border-radius: 999px; + + &:hover { + background: transparent; + } + &.liked, &:not([disabled]):hover { color: #e0506e; + border-color: #e0506e; } } diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts index f39f318c34d..eb689596bf0 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts @@ -31,6 +31,7 @@ import { commonTestProviders } from "../../../../../common/testing/test-utils"; import type { Mocked } from "vitest"; import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; import { HUB_WORKFLOW_RESULT_DETAIL, USER_WORKSPACE } from "../../../../../app-routing.constant"; +import { DatasetService } from "../../../../service/user/dataset/dataset.service"; function makeWorkflowEntry(overrides: Partial = {}): DashboardEntry { return { @@ -48,18 +49,37 @@ function makeWorkflowEntry(overrides: Partial = {}): DashboardEn } as unknown as DashboardEntry; } +function makeDatasetEntry(overrides: Partial = {}): DashboardEntry { + return { + id: 5, + name: "ds", + description: "", + type: "dataset", + dataset: { isOwner: true }, + accessibleUserIds: [], + likeCount: 0, + viewCount: 0, + isLiked: false, + size: 0, + ...overrides, + } as unknown as DashboardEntry; +} + describe("CardItemComponent", () => { let component: CardItemComponent; let fixture: ComponentFixture; let workflowPersistService: Mocked; + let datasetService: Mocked; beforeEach(async () => { const workflowPersistServiceSpy = { updateWorkflowName: vi.fn(), updateWorkflowDescription: vi.fn() }; + const datasetServiceSpy = { getDatasetCoverUrl: vi.fn() }; await TestBed.configureTestingModule({ imports: [CardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], providers: [ { provide: WorkflowPersistService, useValue: workflowPersistServiceSpy }, + { provide: DatasetService, useValue: datasetServiceSpy }, { provide: UserService, useClass: StubUserService }, NzModalService, ...commonTestProviders, @@ -69,6 +89,7 @@ describe("CardItemComponent", () => { fixture = TestBed.createComponent(CardItemComponent); component = fixture.componentInstance; workflowPersistService = TestBed.inject(WorkflowPersistService) as unknown as Mocked; + datasetService = TestBed.inject(DatasetService) as unknown as Mocked; component.entry = makeWorkflowEntry(); fixture.detectChanges(); }); @@ -157,4 +178,33 @@ describe("CardItemComponent", () => { expect((entry as any).checked).toBe(true); expect(spy).toHaveBeenCalledTimes(1); }); + + it("should load the dataset cover into the preview when the entry has a cover", () => { + datasetService.getDatasetCoverUrl.mockReturnValue(of({ url: "https://cover.example/img.png" })); + component.entry = makeDatasetEntry({ id: 5, coverImageUrl: "cover/path.png" }); + component.ngOnChanges({ entry: { currentValue: component.entry } as any }); + + expect(datasetService.getDatasetCoverUrl).toHaveBeenCalledWith(5); + expect(component.previewImage).toBe("https://cover.example/img.png"); + }); + + it("should fall back to the default preview when the cover fetch fails", () => { + datasetService.getDatasetCoverUrl.mockReturnValue(throwError(() => new Error("cover fetch failed"))); + component.entry = makeDatasetEntry({ coverImageUrl: "cover/path.png" }); + component.ngOnChanges({ entry: { currentValue: component.entry } as any }); + + expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE); + }); + + it("should reset the preview to the default image on cover load error", () => { + component.coverImageSrc = "https://cover.example/img.png"; + component.onCoverError(); + expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE); + }); + + it("should keep the default preview for non-dataset entries", () => { + component.entry = makeWorkflowEntry(); + component.ngOnChanges({ entry: { currentValue: component.entry } as any }); + expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE); + }); }); diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts index ffba9fb9abb..44925da678e 100644 --- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts @@ -105,6 +105,8 @@ export class CardItemComponent implements OnChanges { hovering: boolean = false; /** The default top image, used when the user has not uploaded a custom one. */ static readonly DEFAULT_PREVIEW_IMAGE = "assets/card_background.jpg"; + /** Resolved preview/cover image; stays the placeholder until a dataset cover loads. */ + coverImageSrc: string = CardItemComponent.DEFAULT_PREVIEW_IMAGE; @Input() get entry(): DashboardEntry { @@ -136,10 +138,11 @@ export class CardItemComponent implements OnChanges { /** The top image src for the card preview. */ get previewImage(): string { - return CardItemComponent.DEFAULT_PREVIEW_IMAGE; + return this.coverImageSrc; } initializeEntry() { + this.coverImageSrc = CardItemComponent.DEFAULT_PREVIEW_IMAGE; if (this.entry.type === "workflow") { if (typeof this.entry.id === "number") { this.disableDelete = !this.entry.workflow.isOwner; @@ -166,6 +169,7 @@ export class CardItemComponent implements OnChanges { } this.iconType = "database"; this.size = this.entry.size; + this.loadDatasetCover(this.entry.id); } } else if (this.entry.type === "file") { // not sure where to redirect @@ -184,6 +188,31 @@ export class CardItemComponent implements OnChanges { } } + /** Loads the dataset cover into the preview slot, falling back to the placeholder. */ + private loadDatasetCover(did: number): void { + if (!this.entry.coverImageUrl) { + return; + } + this.datasetService + .getDatasetCoverUrl(did) + .pipe(untilDestroyed(this)) + .subscribe({ + next: ({ url }) => { + this.coverImageSrc = url ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE; + this.cdr.markForCheck(); + }, + error: () => { + this.coverImageSrc = CardItemComponent.DEFAULT_PREVIEW_IMAGE; + this.cdr.markForCheck(); + }, + }); + } + + /** Falls the preview back to the placeholder if the cover image fails to load. */ + onCoverError(): void { + this.coverImageSrc = CardItemComponent.DEFAULT_PREVIEW_IMAGE; + } + onCheckboxChange(entry: DashboardEntry): void { entry.checked = !entry.checked; this.cdr.markForCheck(); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html index 08ce28eac60..1c08a580e4a 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html @@ -32,6 +32,26 @@ Datasets nzTheme="outline"> Create Dataset + + + + + + @@ -52,9 +72,24 @@ Datasets + + + + + diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts index f665b3ea81f..95b2deeca00 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.spec.ts @@ -309,4 +309,25 @@ describe("UserDatasetComponent", () => { expect(searchResultsStub.entries).toEqual([e1, e3]); }); }); + + describe("view mode toggle", () => { + const VIEW_MODE_KEY = "texera.userDataset.viewMode"; + + afterEach(() => localStorage.removeItem(VIEW_MODE_KEY)); + + it("setViewType updates viewType, persists it, and is a no-op when unchanged", () => { + component.setViewType("card"); + expect(component.viewType).toBe("card"); + expect(localStorage.getItem(VIEW_MODE_KEY)).toBe("card"); + + // setting the same value should not write again + localStorage.removeItem(VIEW_MODE_KEY); + component.setViewType("card"); + expect(localStorage.getItem(VIEW_MODE_KEY)).toBeNull(); + + component.setViewType("list"); + expect(component.viewType).toBe("list"); + expect(localStorage.getItem(VIEW_MODE_KEY)).toBe("list"); + }); + }); }); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts index 2deedba1048..e2d20e97b1a 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts @@ -26,6 +26,7 @@ import { DatasetService } from "../../../service/user/dataset/dataset.service"; import { SortMethod } from "../../../type/sort-method"; import { DashboardEntry } from "../../../type/dashboard-entry"; import { SearchResultsComponent } from "../search-results/search-results.component"; +import { CardItemComponent } from "../list-item/card-item/card-item.component"; import { FiltersComponent } from "../filters/filters.component"; import { firstValueFrom } from "rxjs"; import { USER_DATASET } from "../../../../app-routing.constant"; @@ -61,14 +62,18 @@ import { FormsModule } from "@angular/forms"; NzSelectComponent, FormsModule, SearchResultsComponent, + CardItemComponent, ], }) export class UserDatasetComponent implements AfterViewInit { + private static readonly VIEW_MODE_STORAGE_KEY = "texera.userDataset.viewMode"; public sortMethod = SortMethod.EditTimeDesc; lastSortMethod: SortMethod | null = null; public isLogin = this.userService.isLogin(); public currentUid = this.userService.getCurrentUser()?.uid; public hasMismatch = false; // Display warning when there are mismatched datasets + public viewType: "list" | "card" = + localStorage.getItem(UserDatasetComponent.VIEW_MODE_STORAGE_KEY) === "card" ? "card" : "list"; private _searchResultsComponent?: SearchResultsComponent; @ViewChild(SearchResultsComponent) get searchResultsComponent(): SearchResultsComponent { @@ -120,6 +125,14 @@ export class UserDatasetComponent implements AfterViewInit { .subscribe(() => this.search()); } + public setViewType(viewType: "list" | "card"): void { + if (this.viewType === viewType) { + return; + } + this.viewType = viewType; + localStorage.setItem(UserDatasetComponent.VIEW_MODE_STORAGE_KEY, viewType); + } + /* * Executes a dataset search with filtering, sorting. * diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html index b910262df7e..15bce4a703a 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html @@ -27,11 +27,9 @@ class="view-toggle"> + [nzType]="viewMode === 'list' ? 'primary' : 'default'"> + [nzType]="viewMode === 'card' ? 'primary' : 'default'"> @@ -56,10 +52,10 @@ - - + diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss index 15c67772751..959c187ed54 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss @@ -28,28 +28,6 @@ .view-toggle { display: inline-flex; - background: #f5f5f5; - border-radius: 6px; - padding: 2px; + gap: 8px; margin-left: auto; - - button { - height: 28px; - padding: 0 10px; - border: none; - background: transparent; - color: #8c8c8c; - border-radius: 4px; - - &:hover { - color: #595959; - background: rgba(255, 255, 255, 0.6); - } - - &.active { - background: #fff; - color: #1f1f1f; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); - } - } } diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts index 1f69bdcff71..2cb582c4e9d 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts @@ -28,7 +28,7 @@ import { SearchResultsViewMode, } from "../../../dashboard/component/user/search-results/search-results.component"; import { FiltersComponent } from "../../../dashboard/component/user/filters/filters.component"; -import { DatasetCardItemComponent } from "../../../dashboard/component/user/dataset-card-item/dataset-card-item.component"; +import { CardItemComponent } from "../../../dashboard/component/user/list-item/card-item/card-item.component"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { SortMethod } from "../../../dashboard/type/sort-method"; import { UserService } from "../../../common/service/user/user.service"; @@ -53,7 +53,7 @@ const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode"; SortButtonComponent, FiltersComponent, SearchResultsComponent, - DatasetCardItemComponent, + CardItemComponent, ], }) export class HubSearchResultComponent implements OnInit, AfterViewInit {