diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala index 89fe805d58c..64c8c311068 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala @@ -48,7 +48,8 @@ object DatasetSearchQueryBuilder extends SearchQueryBuilder with LazyLogging { repositoryName = DATASET.REPOSITORY_NAME, isDatasetPublic = DATASET.IS_PUBLIC, isDatasetDownloadable = DATASET.IS_DOWNLOADABLE, - datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE + datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE, + datasetCoverImage = DATASET.COVER_IMAGE ) /* diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala index dbcf1926407..8c4ecda946b 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala @@ -72,7 +72,8 @@ object UnifiedResourceSchema { repositoryName: Field[String] = DSL.inline(""), isDatasetPublic: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]), isDatasetDownloadable: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]), - datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]) + datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]), + datasetCoverImage: Field[String] = DSL.cast(null, classOf[String]) ): UnifiedResourceSchema = { new UnifiedResourceSchema( Seq( @@ -96,7 +97,8 @@ object UnifiedResourceSchema { repositoryName -> repositoryName.as("repository_name"), isDatasetPublic -> isDatasetPublic.as("is_dataset_public"), isDatasetDownloadable -> isDatasetDownloadable.as("is_dataset_downloadable"), - datasetUserAccess -> datasetUserAccess.as("user_dataset_access") + datasetUserAccess -> datasetUserAccess.as("user_dataset_access"), + datasetCoverImage -> datasetCoverImage.as("cover_image") ) ) } diff --git a/frontend/src/app/common/util/format.util.ts b/frontend/src/app/common/util/format.util.ts index 2ac5f229796..8de69332d59 100644 --- a/frontend/src/app/common/util/format.util.ts +++ b/frontend/src/app/common/util/format.util.ts @@ -54,3 +54,40 @@ export const formatTime = (seconds?: number): string => { return min === 0 ? `${h}h` : `${h}h${min}m`; }; + +/** Format a count: "1.5k" for >= 1000, otherwise the number. */ +export const formatCount = (count: number): string => { + if (count >= 1000) return (count / 1000).toFixed(1) + "k"; + return String(count); +}; + +/** Format a timestamp as relative time ("5 minutes ago", "3 months ago", "1 year ago"). */ +export const formatRelativeTime = (timestamp: number | undefined): string => { + if (timestamp === undefined) { + return "Unknown"; + } + + const currentTime = new Date().getTime(); + const timeDifference = currentTime - timestamp; + + const minutesAgo = Math.floor(timeDifference / (1000 * 60)); + const hoursAgo = Math.floor(timeDifference / (1000 * 60 * 60)); + const daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); + const weeksAgo = Math.floor(daysAgo / 7); + const monthsAgo = Math.floor(daysAgo / 30); + const yearsAgo = Math.floor(daysAgo / 365); + + if (minutesAgo < 60) { + return `${minutesAgo} minutes ago`; + } else if (hoursAgo < 24) { + return `${hoursAgo} hours ago`; + } else if (daysAgo < 7) { + return `${daysAgo} days ago`; + } else if (weeksAgo < 4) { + return `${weeksAgo} weeks ago`; + } else if (monthsAgo < 12) { + return `${monthsAgo} months ago`; + } else { + return `${yearsAgo} years ago`; + } +}; 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 new file mode 100644 index 00000000000..0d333bd5e88 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html @@ -0,0 +1,158 @@ + + + + + + + +
+ dataset cover + #{{ 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 new file mode 100644 index 00000000000..c72c2e50cd0 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss @@ -0,0 +1,223 @@ +/** + * 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-row { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 10px; + + .card-title { + flex: 1; + min-width: 0; + height: calc(15px * 1.35 * 2); + font-size: 15px; + font-weight: 600; + line-height: 1.35; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + word-break: break-word; + } + + .more-btn { + flex-shrink: 0; + width: 26px; + height: 26px; + border: none; + background: transparent; + color: #8c8c8c; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + i { + font-size: 18px; + } + } +} + +.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 new file mode 100644 index 00000000000..f7261b9e772 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts @@ -0,0 +1,139 @@ +/** + * 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 } from "rxjs"; +import type { Mocked } from "vitest"; + +import { DatasetCardItemComponent } from "./dataset-card-item.component"; +import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { DatasetService } from "../../../service/user/dataset/dataset.service"; +import { DownloadService } from "../../../service/user/download/download.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 { AppSettings } from "../../../../common/app-setting"; +import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../../../../app-routing.constant"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; + +function makeDatasetEntry(overrides: Partial = {}): DashboardEntry { + // Only includes fields read by the component's logic; template fields are skipped + 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: NzModalService, useValue: { create: vi.fn() } }, + { provide: DatasetService, useValue: { retrieveOwners: vi.fn().mockReturnValue(of([])) } }, + { provide: DownloadService, useValue: { downloadDataset: vi.fn().mockReturnValue(of(new Blob())) } }, + { 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([DASHBOARD_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([DASHBOARD_HUB_DATASET_RESULT_DETAIL, "99"]); + }); + }); + + describe("coverImageSrc", () => { + it("falls back to the default cover when coverImageUrl is missing", () => { + component.entry = makeDatasetEntry({ coverImageUrl: undefined }); + component.ngOnChanges({ entry: { currentValue: component.entry } } as any); + expect(component.coverImageSrc).toBe(component.defaultCover); + }); + + it("builds the API URL when coverImageUrl is set", () => { + component.entry = makeDatasetEntry({ id: 7, coverImageUrl: "v1/img.png" }); + component.ngOnChanges({ entry: { currentValue: component.entry } } as any); + expect(component.coverImageSrc).toBe(`${AppSettings.getApiEndpoint()}/dataset/7/cover`); + }); + }); + + 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 new file mode 100644 index 00000000000..c5d33bbcbe5 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts @@ -0,0 +1,182 @@ +/** + * 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, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, +} from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { NgIf } from "@angular/common"; +import { RouterLink } from "@angular/router"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; +import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; +import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { firstValueFrom } from "rxjs"; +import { DashboardEntry } from "../../../type/dashboard-entry"; +import { UserAvatarComponent } from "../user-avatar/user-avatar.component"; +import { ShareAccessComponent } from "../share-access/share-access.component"; +import { DatasetService } from "../../../service/user/dataset/dataset.service"; +import { DownloadService } from "../../../service/user/download/download.service"; +import { HubService } from "../../../../hub/service/hub.service"; +import { AppSettings } from "../../../../common/app-setting"; +import { formatSize } from "../../../../common/util/size-formatter.util"; +import { formatCount, formatRelativeTime } from "../../../../common/util/format.util"; +import { isDefined } from "../../../../common/util/predicate"; +import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_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: [ + NgIf, + RouterLink, + NzCardComponent, + NzIconDirective, + NzPopconfirmDirective, + NzTooltipModule, + NzDropdownDirective, + NzDropdownMenuComponent, + NzMenuDirective, + NzMenuItemComponent, + UserAvatarComponent, + ], +}) +export class DatasetCardItemComponent implements OnChanges { + @Input() editable = false; + @Input() currentUid: number | undefined; + @Input() entry!: DashboardEntry; + @Output() deleted = new EventEmitter(); + @Output() refresh = new EventEmitter(); + + entryLink: string[] = []; + coverImageSrc: string = ""; + readonly defaultCover = "assets/card_background.jpg"; + likeCount = 0; + viewCount = 0; + isLiked = false; + + constructor( + private modalService: NzModalService, + private datasetService: DatasetService, + private downloadService: DownloadService, + 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.type !== "dataset" || typeof this.entry.id !== "number") { + return; + } + const owners = this.entry.accessibleUserIds; + if (this.currentUid !== undefined && owners.includes(this.currentUid)) { + this.entryLink = [DASHBOARD_USER_DATASET, String(this.entry.id)]; + } else { + this.entryLink = [DASHBOARD_HUB_DATASET_RESULT_DETAIL, String(this.entry.id)]; + } + this.coverImageSrc = this.entry.coverImageUrl + ? `${AppSettings.getApiEndpoint()}/dataset/${this.entry.id}/cover` + : this.defaultCover; + } + + onCoverError(event: Event): void { + const image = event.target as HTMLImageElement; + image.onerror = null; + image.src = this.defaultCover; + } + + public async onClickOpenShareAccess(): Promise { + if (this.entry.type !== "dataset") return; + const modal = this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: this.entry.accessLevel === "WRITE", + type: "dataset", + id: this.entry.id, + allOwners: await firstValueFrom(this.datasetService.retrieveOwners()), + }, + nzFooter: null, + nzTitle: "Share this dataset with others", + nzCentered: true, + nzWidth: "700px", + }); + modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => this.refresh.emit()); + } + + public onClickDownload = (): void => { + if (this.entry.type !== "dataset" || !this.entry.id) return; + this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe(); + }; + + toggleLike(): void { + if (!isDefined(this.currentUid) || !isDefined(this.entry.id)) return; + // Flip optimistically; reconcile or revert when the server responds. + 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(); + }, + }); + } + + get canDelete(): boolean { + return this.entry.type === "dataset" && this.entry.dataset.isOwner; + } + + formatSize = formatSize; + formatCount = formatCount; + formatRelativeTime = formatRelativeTime; +} diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html index d623b981532..084ccb0d9fc 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html @@ -20,36 +20,60 @@ - + + - - - - - - - -
- + + + + + + + +
+ +
+
+ + + + +
+
+ + +
+
+ +
- +
diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss b/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss index 73ccb3127ae..e5e237e23a5 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss @@ -130,3 +130,21 @@ nz-content { margin: 0 1rem 0 0; // add space to the right } } + +.card-scroll-container { + height: 100%; + overflow-y: auto; + padding: 12px; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; + justify-content: start; + align-items: stretch; + + * { + max-width: 300px; + } +} diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts b/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts index 601a6534a53..baddea9f68b 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts @@ -17,13 +17,13 @@ * under the License. */ -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, Input, Output, TemplateRef } from "@angular/core"; import { DashboardEntry } from "../../../type/dashboard-entry"; import { UserService } from "../../../../common/service/user/user.service"; import { NzCardComponent } from "ng-zorro-antd/card"; import { ɵɵCdkVirtualScrollViewport, ɵɵCdkFixedSizeVirtualScroll } from "@angular/cdk/overlay"; import { NzListComponent } from "ng-zorro-antd/list"; -import { NgFor, NgIf } from "@angular/common"; +import { NgFor, NgIf, NgTemplateOutlet } from "@angular/common"; import { ListItemComponent } from "../list-item/list-item.component"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; @@ -31,6 +31,7 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; export type LoadMoreFunction = (start: number, count: number) => Promise<{ entries: DashboardEntry[]; more: boolean }>; +export type SearchResultsViewMode = "list" | "card"; @Component({ selector: "texera-search-results", @@ -44,6 +45,7 @@ export type LoadMoreFunction = (start: number, count: number) => Promise<{ entri NgFor, ListItemComponent, NgIf, + NgTemplateOutlet, NzSpaceCompactItemDirective, NzButtonComponent, NzWaveDirective, @@ -62,6 +64,11 @@ export class SearchResultsComponent { @Input() editable = false; @Input() searchKeywords: string[] = []; @Input() currentUid: number | undefined; + @Input() viewMode: SearchResultsViewMode = "list"; + /** Template rendered for each entry in card mode; receives the entry via $implicit. */ + @Input() cardTemplate?: TemplateRef<{ $implicit: DashboardEntry }>; + + trackByEntryId = (_: number, entry: DashboardEntry): string => `${entry.type}-${entry.id}`; @Output() deleted = new EventEmitter(); @Output() duplicated = new EventEmitter(); @Output() modified = new EventEmitter(); 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 1ce36ff5ab3..b910262df7e 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 @@ -22,14 +22,53 @@
+
+ + +
+ + + + +
+ [currentUid]="this.currentUid" + [viewMode]="searchType === 'dataset' ? viewMode : 'list'" + [cardTemplate]="searchType === 'dataset' ? datasetCardTpl : undefined">
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 e172c5b40bd..15c67772751 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 @@ -18,3 +18,38 @@ */ @import "../../../dashboard/component/user/search/search.component"; + +.filter { + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.view-toggle { + display: inline-flex; + background: #f5f5f5; + border-radius: 6px; + padding: 2px; + 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 067e9205625..1f69bdcff71 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 @@ -19,8 +19,16 @@ import { AfterViewInit, Component, Input, OnInit, ViewChild } from "@angular/core"; import { Router } from "@angular/router"; -import { SearchResultsComponent } from "../../../dashboard/component/user/search-results/search-results.component"; +import { NgIf } from "@angular/common"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; +import { + SearchResultsComponent, + 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 { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { SortMethod } from "../../../dashboard/type/sort-method"; import { UserService } from "../../../common/service/user/user.service"; @@ -30,17 +38,36 @@ import { firstValueFrom } from "rxjs"; import { map } from "rxjs/operators"; import { SortButtonComponent } from "../../../dashboard/component/user/sort-button/sort-button.component"; +const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode"; + @UntilDestroy() @Component({ selector: "texera-hub-search", templateUrl: "./hub-search-result.component.html", styleUrls: ["./hub-search-result.component.scss"], - imports: [SortButtonComponent, FiltersComponent, SearchResultsComponent], + imports: [ + NgIf, + NzButtonComponent, + NzIconDirective, + NzTooltipModule, + SortButtonComponent, + FiltersComponent, + SearchResultsComponent, + DatasetCardItemComponent, + ], }) export class HubSearchResultComponent implements OnInit, AfterViewInit { public searchType: "dataset" | "workflow" = "workflow"; public searchKeywords: string[] = []; currentUid = this.userService.getCurrentUser()?.uid; + public viewMode: SearchResultsViewMode = + localStorage.getItem(HUB_DATASET_VIEW_MODE_STORAGE_KEY) === "card" ? "card" : "list"; + + setViewMode(mode: SearchResultsViewMode): void { + if (this.viewMode === mode) return; + this.viewMode = mode; + localStorage.setItem(HUB_DATASET_VIEW_MODE_STORAGE_KEY, mode); + } private isLogin = false; private includePublic = true; diff --git a/frontend/src/app/hub/service/hub.service.ts b/frontend/src/app/hub/service/hub.service.ts index 66cd08a9149..7979bcbaca6 100644 --- a/frontend/src/app/hub/service/hub.service.ts +++ b/frontend/src/app/hub/service/hub.service.ts @@ -20,6 +20,7 @@ import { HttpClient, HttpHeaders, HttpParams } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; +import { map, switchMap } from "rxjs/operators"; import { AppSettings } from "../../common/app-setting"; import { SearchResultItem } from "../../dashboard/type/search-result"; @@ -102,6 +103,26 @@ export class HubService { }); } + /** Like/unlike then fetch updated count; emits the final {liked, likeCount}. */ + public toggleLike( + entityId: number, + entityType: EntityType, + currentlyLiked: boolean + ): Observable<{ liked: boolean; likeCount: number }> { + const action$ = currentlyLiked ? this.postUnlike(entityId, entityType) : this.postLike(entityId, entityType); + return action$.pipe( + switchMap(success => + this.getCounts([entityType], [entityId], [ActionType.Like]).pipe( + map(counts => { + const likeCount = counts[0]?.counts.like ?? 0; + const liked = success ? !currentlyLiked : currentlyLiked; + return { liked, likeCount }; + }) + ) + ) + ); + } + public postView(entityId: number, userId: number, entityType: EntityType): Observable { const body = { entityId, userId, entityType }; return this.http.post(`${this.BASE_URL}/view`, body, {