diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index a591207f605..0bfe9c0b6a9 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -157,6 +157,7 @@ import { ResultExportationComponent } from "./workspace/component/result-exporta import { ReportGenerationService } from "./workspace/service/report-generation/report-generation.service"; import { SearchBarComponent } from "./dashboard/component/user/search-bar/search-bar.component"; import { ListItemComponent } from "./dashboard/component/user/list-item/list-item.component"; +import { CardItemComponent } from "./dashboard/component/user/list-item/card-item/card-item.component"; import { HubComponent } from "./hub/component/hub.component"; import { HubWorkflowDetailComponent } from "./hub/component/workflow/detail/hub-workflow-detail.component"; import { LandingPageComponent } from "./hub/component/landing-page/landing-page.component"; @@ -270,6 +271,7 @@ registerLocaleData(en); HighlightSearchTermsPipe, SearchBarComponent, ListItemComponent, + CardItemComponent, HubComponent, HubWorkflowDetailComponent, LandingPageComponent, 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 new file mode 100644 index 00000000000..e164413c08d --- /dev/null +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html @@ -0,0 +1,245 @@ + + + +
+ +
+ +
+ +
+ +
+ + Workflow Preview +
+ + +
+ +
+
+ + +
+
+ {{ entry.name }} +
+ +
+
+ + + +
+ + +
+
+ {{ entry.description ? entry.description : (hovering ? 'Write a description...' : '') }} +
+ +
+ + +
+
+
+ + {{ formatTime(entry.creationTime) }} + + {{ formatSize(size) }} +
+
+ + {{ formatTime(entry.lastModifiedTime) }} + + {{ formatCount(viewCount) }} +
+
+
+ + +
+ + + + +
+ + + + + +
+
+
+
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 new file mode 100644 index 00000000000..43a57ab71e1 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.scss @@ -0,0 +1,218 @@ +/** + * 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. + */ +.card-item { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + transition: box-shadow 0.2s; + overflow: hidden; + border-radius: 8px; + + &:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &.selected { + border-color: #1e90ff; + background-color: #e6f7ff; + } + + ::ng-deep .ant-card-body { + height: 100%; + display: flex; + flex-direction: column; + } +} + +.card-preview { + height: 130px; + background-color: #f5f5f5; + position: relative; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + + &:hover { + background-color: #e8e8e8; + } +} + +.card-preview-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.card-checkbox { + position: absolute; + top: 8px; + left: 8px; + z-index: 10; +} + +.card-user-avatar { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; +} + +.card-content { + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; +} + +.title-container { + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; + flex: 1; +} + +.type-icon { + font-size: 20px; + color: #1890ff; +} + +.name-container { + flex: 1; + overflow: hidden; +} + +.resource-name { + font-size: 16px; + font-weight: 500; + color: rgba(0, 0, 0, 0.85); +} + +.resource-name-edit-input { + width: 100%; + font-size: 16px; + padding: 2px 4px; +} + +.edit-btn { + opacity: 0; + transition: opacity 0.2s; +} + +.card-item:hover .edit-btn { + opacity: 1; +} + +.card-description { + flex: 0 0 auto; + /* Do not grow */ + height: 60px; + /* Fixed height */ + cursor: pointer; + overflow: hidden; + margin-bottom: 8px; +} + +.resource-description { + font-size: 13px; + color: rgba(0, 0, 0, 0.45); + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + height: 100%; +} + +.resource-description-edit-textarea { + width: 100%; + height: 100%; + resize: none; + font-size: 13px; + border: 1px solid #d9d9d9; + border-radius: 4px; +} + +.card-meta { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + margin-top: auto; +} + +.meta-details { + display: flex; + flex-direction: column; + gap: 4px; +} + +.meta-row { + display: flex; + justify-content: space-between; + align-items: center; + + span { + display: flex; + align-items: center; + gap: 4px; + } +} + +.card-actions { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid #f0f0f0; + padding-top: 8px; + margin-top: 8px; +} + +.action-btn { + padding: 4px 8px; + color: rgba(0, 0, 0, 0.45); + + &:hover { + color: #1890ff; + } +} + +.liked { + color: #ff4d4f; +} + +.delete-btn:hover { + color: #ff4d4f; +} + +.truncate-single-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} 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 new file mode 100644 index 00000000000..02bd761eff4 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts @@ -0,0 +1,427 @@ +/** + * 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 { + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + ViewChild, +} from "@angular/core"; +// import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { untilDestroyed } from "@ngneat/until-destroy"; +import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; +import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; +import { ShareAccessComponent } from "../../share-access/share-access.component"; +import { + DEFAULT_WORKFLOW_NAME, + WorkflowPersistService, +} from "src/app/common/service/workflow-persist/workflow-persist.service"; +import { firstValueFrom } from "rxjs"; +import { HubWorkflowDetailComponent } from "../../../../../hub/component/workflow/detail/hub-workflow-detail.component"; +import { ActionType, HubService } from "../../../../../hub/service/hub.service"; +import { DownloadService } from "src/app/dashboard/service/user/download/download.service"; +import { formatSize } from "src/app/common/util/size-formatter.util"; +import { DatasetService, DEFAULT_DATASET_NAME } from "../../../../service/user/dataset/dataset.service"; +import { NotificationService } from "../../../../../common/service/notification/notification.service"; +import { + DASHBOARD_HUB_DATASET_RESULT_DETAIL, + DASHBOARD_HUB_WORKFLOW_RESULT_DETAIL, + DASHBOARD_USER_DATASET, + DASHBOARD_USER_PROJECT, + DASHBOARD_USER_WORKSPACE, +} from "../../../../../app-routing.constant"; +import { isDefined } from "../../../../../common/util/predicate"; + +// @UntilDestroy() +@Component({ + selector: "texera-card-item", + templateUrl: "./card-item.component.html", + styleUrls: ["./card-item.component.scss"], +}) +export class CardItemComponent implements OnChanges { + private owners: number[] = []; + public originalName: string = ""; + public originalDescription: string | undefined = undefined; + public disableDelete: boolean = false; + @Input() currentUid: number | undefined; + @ViewChild("nameInput") nameInput!: ElementRef; + @ViewChild("descriptionInput") descriptionInput!: ElementRef; + editingName = false; + editingDescription = false; + likeCount: number = 0; + viewCount = 0; + entryLink: string[] = []; + size: number | undefined = 0; + public iconType: string = ""; + isLiked: boolean = false; + @Input() isPrivateSearch = false; + @Input() editable = false; + private _entry?: DashboardEntry; + hovering: boolean = false; + + @Input() + get entry(): DashboardEntry { + if (!this._entry) { + throw new Error("entry property must be provided."); + } + return this._entry; + } + + set entry(value: DashboardEntry) { + this._entry = value; + } + + @Output() checkboxChanged = new EventEmitter(); + @Output() deleted = new EventEmitter(); + @Output() duplicated = new EventEmitter(); + @Output() refresh = new EventEmitter(); + + constructor( + private modalService: NzModalService, + private workflowPersistService: WorkflowPersistService, + private datasetService: DatasetService, + private modal: NzModalService, + private hubService: HubService, + private downloadService: DownloadService, + private cdr: ChangeDetectorRef, + private notificationService: NotificationService + ) {} + + initializeEntry() { + if (this.entry.type === "workflow") { + if (typeof this.entry.id === "number") { + this.disableDelete = !this.entry.workflow.isOwner; + this.owners = this.entry.accessibleUserIds; + if (this.currentUid !== undefined && this.owners.includes(this.currentUid)) { + this.entryLink = [DASHBOARD_USER_WORKSPACE, String(this.entry.id)]; + } else { + this.entryLink = [DASHBOARD_HUB_WORKFLOW_RESULT_DETAIL, String(this.entry.id)]; + } + this.size = this.entry.size; + } + this.iconType = "project"; + } else if (this.entry.type === "project") { + this.entryLink = [DASHBOARD_USER_PROJECT, String(this.entry.id)]; + this.iconType = "container"; + } else if (this.entry.type === "dataset") { + if (typeof this.entry.id === "number") { + this.disableDelete = !this.entry.dataset.isOwner; + this.owners = this.entry.accessibleUserIds; + if (this.currentUid !== undefined && this.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.iconType = "database"; + this.size = this.entry.size; + } + } else if (this.entry.type === "file") { + // not sure where to redirect + this.iconType = "folder-open"; + } else { + throw new Error("Unexpected type in DashboardEntry."); + } + this.likeCount = this.entry.likeCount; + this.viewCount = this.entry.viewCount; + this.isLiked = this.entry.isLiked; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes["entry"]) { + this.initializeEntry(); + } + } + + onCheckboxChange(entry: DashboardEntry): void { + entry.checked = !entry.checked; + this.cdr.markForCheck(); + this.checkboxChanged.emit(); + } + + public async onClickOpenShareAccess(): Promise { + let modal: NzModalRef | undefined; + + if (this.entry.type === "workflow") { + modal = this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: this.entry.workflow.accessLevel === "WRITE", + type: this.entry.type, + id: this.entry.id, + allOwners: await firstValueFrom(this.workflowPersistService.retrieveOwners()), + inWorkspace: false, + }, + nzFooter: null, + nzTitle: "Share this workflow with others", + nzCentered: true, + nzWidth: "700px", + }); + } else if (this.entry.type === "dataset") { + 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", + }); + } + if (modal) { + modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => { + this.refresh.emit(); + }); + } + } + + public onClickDownload = (): void => { + if (!this.entry.id) return; + + if (this.entry.type === "workflow") { + this.downloadService + .downloadWorkflow(this.entry.id, this.entry.workflow.workflow.name) + .pipe(untilDestroyed(this)) + .subscribe(); + } else if (this.entry.type === "dataset") { + this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe(); + } + }; + + onEditName(): void { + this.originalName = this.entry.name; + this.editingName = true; + setTimeout(() => { + if (this.nameInput) { + const inputElement = this.nameInput.nativeElement; + const valueLength = inputElement.value.length; + inputElement.focus(); + inputElement.setSelectionRange(valueLength, valueLength); + } + }, 0); + } + + onEditDescription(): void { + this.originalDescription = this.entry.description; + this.editingDescription = true; + setTimeout(() => { + if (this.descriptionInput) { + const textareaElement = this.descriptionInput.nativeElement; + const valueLength = textareaElement.value.length; + textareaElement.focus(); + textareaElement.setSelectionRange(valueLength, valueLength); + } + }, 0); + } + + private updateProperty( + updateMethod: (id: number, value: string) => any, + propertyName: "name" | "description", + newValue: string, + originalValue: string | undefined + ): void { + if (!this.entry.id) { + this.notificationService.error("Id is missing"); + return; + } + + updateMethod(this.entry.id, newValue) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.entry[propertyName] = newValue; // Dynamic property assignment + }, + error: () => { + this.notificationService.error("Update failed"); + (this.entry as any)[propertyName] = originalValue ?? ""; // Fallback to original value + this.setEditingState(propertyName, false); + }, + complete: () => { + this.setEditingState(propertyName, false); + }, + }); + } + + private setEditingState(propertyName: "name" | "description", state: boolean): void { + if (propertyName === "name") { + this.editingName = state; + } else if (propertyName === "description") { + this.editingDescription = state; + } + } + + public confirmUpdateCustomName(name: string): void { + if (this.entry.name === this.originalName) { + this.editingName = false; + return; + } + const newName = this.entry.type === "workflow" ? name || DEFAULT_WORKFLOW_NAME : name || DEFAULT_DATASET_NAME; + + if (this.entry.type === "workflow") { + this.updateProperty( + this.workflowPersistService.updateWorkflowName.bind(this.workflowPersistService), + "name", + newName, + this.originalName + ); + } else if (this.entry.type === "dataset") { + this.updateProperty( + this.datasetService.updateDatasetName.bind(this.datasetService), + "name", + newName, + this.originalName + ); + } + } + + public confirmUpdateCustomDescription(description: string | undefined): void { + if (this.entry.description === this.originalDescription) { + this.editingDescription = false; + return; + } + const updatedDescription = description ?? ""; + + if (this.entry.type === "workflow") { + this.updateProperty( + this.workflowPersistService.updateWorkflowDescription.bind(this.workflowPersistService), + "description", + updatedDescription, + this.originalDescription + ); + } else if (this.entry.type === "dataset") { + this.updateProperty( + this.datasetService.updateDatasetDescription.bind(this.datasetService), + "description", + updatedDescription, + this.originalDescription + ); + } + } + + formatTime(timestamp: number | undefined): string { + if (timestamp === undefined) { + return "Unknown"; // Return "Unknown" if the timestamp is undefined + } + + 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); + + 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 { + return new Date(timestamp).toLocaleDateString(); + } + } + + openDetailModal(wid: number | undefined): void { + const modalRef = this.modal.create({ + nzTitle: "Workflow Detail", + nzContent: HubWorkflowDetailComponent, + nzData: { + wid: wid ?? 0, + }, + nzFooter: null, + nzStyle: { width: "60%" }, + nzBodyStyle: { maxHeight: "70vh", overflow: "auto" }, + }); + + const instance = modalRef.componentInstance; + if (instance) { + if (wid !== undefined) { + this.hubService + .getCounts([this.entry.type], [wid], [ActionType.View]) + .pipe(untilDestroyed(this)) + .subscribe(counts => { + const count = counts[0]; + this.viewCount = (count?.counts.view ?? 0) + 1; // hacky fix to display view correctly + }); + } + } + } + + toggleLike(): void { + const userId = this.currentUid; + if (!isDefined(userId) || !isDefined(this.entry.id)) { + return; + } + + const entryId = this.entry.id!; + + if (this.isLiked) { + this.hubService + .postUnlike(entryId, this.entry.type) + .pipe(untilDestroyed(this)) + .subscribe((success: boolean) => { + if (success) { + this.isLiked = false; + this.hubService + .getCounts([this.entry.type], [entryId], [ActionType.Like]) + .pipe(untilDestroyed(this)) + .subscribe(counts => { + this.likeCount = counts[0].counts.like ?? 0; + }); + } + }); + } else { + this.hubService + .postLike(entryId, this.entry.type) + .pipe(untilDestroyed(this)) + .subscribe((success: boolean) => { + if (success) { + this.isLiked = true; + this.hubService + .getCounts([this.entry.type], [entryId], [ActionType.Like]) + .pipe(untilDestroyed(this)) + .subscribe(counts => { + this.likeCount = counts[0].counts.like ?? 0; + }); + } + }); + } + } + + formatCount(count: number): string { + if (count >= 1000) { + return (count / 1000).toFixed(1) + "k"; + } + return count.toString(); + } + + // alias for formatSize + formatSize = formatSize; +} 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..94b6cac8538 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 @@ -27,7 +27,7 @@ - + + +
+ + + + +
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..003a5b3db09 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,17 @@ nz-content { margin: 0 1rem 0 0; // add space to the right } } + +.card-grid-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 16px; + padding: 8px; +} + +.workflow-card-item { + ::ng-deep .card-item { + aspect-ratio: 10/12; + margin-bottom: 0; + } +} 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 f120ae8adc8..2c6702cdf44 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 @@ -40,6 +40,7 @@ export class SearchResultsComponent { @Input() editable = false; @Input() searchKeywords: string[] = []; @Input() currentUid: number | undefined; + @Input() viewType: "list" | "card" = "list"; @Output() deleted = new EventEmitter(); @Output() duplicated = new EventEmitter(); @Output() modified = new EventEmitter(); diff --git a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html index d9d56bbda04..be8513b90a5 100644 --- a/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html +++ b/frontend/src/app/dashboard/component/user/user-workflow/user-workflow.component.html @@ -131,6 +131,26 @@

Workflows

nzTheme="outline" nzType="minus-square"> + + Workflows