diff --git a/src/app/core/resolves/record-resolve-v2.service.ts b/src/app/core/resolves/record-resolve-v2.service.ts new file mode 100644 index 000000000..378fc284d --- /dev/null +++ b/src/app/core/resolves/record-resolve-v2.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import * as _ from 'lodash'; + +import { ApiService } from '@shared/services/api/api.service'; +import { DataService } from '@shared/services/data/data.service'; +import { MessageService } from '@shared/services/message/message.service'; + +import { RecordResponse } from '@shared/services/api/index.repo'; +import { RecordVO } from '@root/app/models'; +import { DataStatus } from '@models/data-status.enum'; + +@Injectable() +export class RecordResolveV2Service { + constructor( + private api: ApiService, + private dataService: DataService, + private message: MessageService, + private route: ActivatedRoute, + ) {} + + async resolve( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + ): Promise { + const localItem = this.dataService.getItemByArchiveNbr( + route.params.recArchiveNbr, + ); + + const itemId = route.params.recordId; + + const token = route.params.token; + + const headers = { + 'X-Permanent-Share-Token': token, + }; + + try { + if (localItem && localItem.dataStatus === DataStatus.Full) { + return Promise.resolve(localItem as RecordVO); + } else if (localItem) { + await this.dataService.fetchFullItems([localItem]); + return localItem as RecordVO; + } else { + const response = await this.api.record.get( + [ + new RecordVO({ + recordId: itemId, + }), + ], + true, + headers, + ); + + const record: any = response[0]; + if (record) { + record.dataStatus = DataStatus.Full; + } + return record; + } + } catch (err) { + if (err instanceof RecordResponse) { + this.message.showError({ message: err.getMessage(), translate: true }); + } + throw err; + } + } +} diff --git a/src/app/file-browser/components/file-list-controls/file-list-controls.component.ts b/src/app/file-browser/components/file-list-controls/file-list-controls.component.ts index 10d99130e..5b41e0da3 100644 --- a/src/app/file-browser/components/file-list-controls/file-list-controls.component.ts +++ b/src/app/file-browser/components/file-list-controls/file-list-controls.component.ts @@ -158,8 +158,8 @@ export class FileListControlsComponent implements OnDestroy, HasSubscriptions { setAvailableActions() { this.isShareRoot = - this.data.currentFolder.type === 'type.folder.root.share'; - this.isPublic = this.data.currentFolder.type.includes('public'); + this.data.currentFolder?.type === 'type.folder.root.share'; + this.isPublic = this.data.currentFolder?.type?.includes('public'); this.setAllActions(false); const isSingleItem = this.selectedItems.length === 1; diff --git a/src/app/file-browser/components/file-list-item/file-list-item.component.ts b/src/app/file-browser/components/file-list-item/file-list-item.component.ts index 3d6f5ff24..8f9629e83 100644 --- a/src/app/file-browser/components/file-list-item/file-list-item.component.ts +++ b/src/app/file-browser/components/file-list-item/file-list-item.component.ts @@ -548,7 +548,7 @@ export class FileListItemComponent rootUrl = '/private'; } - if (this.item.isFolder) { + if (this.item.isFolder || (this.item as FolderVO).folderId) { if (this.checkFolderView && this.isFolderViewSet()) { this.router.navigate([ rootUrl, @@ -563,9 +563,24 @@ export class FileListItemComponent }); } if (this.isInSharePreview || this.isInPublicArchive) { - this.router.navigate([this.item.archiveNbr, this.item.folder_linkId], { - relativeTo: this.route.parent, - }); + const params = this.route.snapshot.params; + const token = params.token; + const itemId = (this.item as FolderVO).folderId; + if (token && itemId) { + this.router.navigate([ + '/share/view/v2-file-list', + 'folder', + token, + itemId, + ]); + } else { + this.router.navigate( + [this.item.archiveNbr, this.item.folder_linkId], + { + relativeTo: this.route.parent, + }, + ); + } } else { this.router.navigate([ rootUrl, @@ -580,9 +595,18 @@ export class FileListItemComponent ) { this.router.navigate(['/shares/record', this.item.archiveNbr]); } else { - this.router.navigate(['record', this.item.archiveNbr], { - relativeTo: this.route, - }); + if (this.item.archiveNumber) { + this.router.navigate( + ['record', 'v2', (this.item as RecordVO).recordId], + { + relativeTo: this.route, + }, + ); + } else if (this.item.archiveNbr) { + this.router.navigate(['record', this.item.archiveNbr], { + relativeTo: this.route, + }); + } } } @@ -1120,7 +1144,7 @@ export class FileListItemComponent private showFolderIcon(): boolean { return ( - this.item.isFolder && + (this.item.isFolder || this.item.folderId) && this.folderContentsType !== FolderContentsType.NORMAL ); } diff --git a/src/app/file-browser/components/file-list-v2/file-list-v2.component.html b/src/app/file-browser/components/file-list-v2/file-list-v2.component.html new file mode 100644 index 000000000..78cac95f7 --- /dev/null +++ b/src/app/file-browser/components/file-list-v2/file-list-v2.component.html @@ -0,0 +1,41 @@ + + +
+
+ This folder is empty +
+ +
+ +
+ +
diff --git a/src/app/file-browser/components/file-list-v2/file-list-v2.component.scss b/src/app/file-browser/components/file-list-v2/file-list-v2.component.scss new file mode 100644 index 000000000..b8c7847d2 --- /dev/null +++ b/src/app/file-browser/components/file-list-v2/file-list-v2.component.scss @@ -0,0 +1,55 @@ +@import 'variables'; + +:host { + width: 100%; + @include has-breadcrumbs; + + &.no-padding { + padding-top: 0px; + } + + &.file-list-centered { + @include public-centered; + } + + .file-list-scroll { + opacity: 1; + + &.is-sorting { + opacity: 0.5; + cursor: wait; + * { + pointer-events: none; + } + } + } +} + +.empty-message { + padding: 20px 10px; + text-align: center; +} + +.file-list-drag-target { + width: calc(100% - #{$sidebar-width}); + border: 5px solid $PR-orange; + position: fixed; + top: $file-list-controls-height; + left: 0; + right: $sidebar-width; + bottom: 0; + z-index: -1; + opacity: 0; + + font-weight: 700; + display: flex; + justify-content: center; + align-items: center; + + &.active { + z-index: 10; + opacity: 1; + transition: opacity 0.25s $tweaked-ease; + background: rgba(white, 0.7); + } +} diff --git a/src/app/file-browser/components/file-list-v2/file-list-v2.component.spec.ts b/src/app/file-browser/components/file-list-v2/file-list-v2.component.spec.ts new file mode 100644 index 000000000..8457fb475 --- /dev/null +++ b/src/app/file-browser/components/file-list-v2/file-list-v2.component.spec.ts @@ -0,0 +1,106 @@ +/* @format */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DataService } from '@shared/services/data/data.service'; +import { AccountService } from '@shared/services/account/account.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Location } from '@angular/common'; +import { DragService } from '@shared/services/drag/drag.service'; +import { FolderViewService } from '@shared/services/folder-view/folder-view.service'; +import { DeviceService } from '@shared/services/device/device.service'; +import { EventService } from '@shared/services/event/event.service'; +import { of, Subject } from 'rxjs'; +import { FolderVO } from '@models/folder-vo'; +import { RouterTestingModule } from '@angular/router/testing'; +import { CdkPortal } from '@angular/cdk/portal'; +import { FileListItemComponent } from '@fileBrowser/components/file-list-item/file-list-item.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FileListV2Component } from './file-list-v2.component'; + +describe('FileListV2Component', () => { + let component: FileListV2Component; + let fixture: ComponentFixture; + + const folderViewServiceMock = { + folderView: 'List', + viewChange: new Subject(), + }; + + const dragServiceMock = { + events: jasmine.createSpy('events').and.returnValue(of({ type: 'start' })), + }; + + const deviceServiceMock = { + isMobileWidth: jasmine.createSpy('isMobileWidth').and.returnValue(false), + }; + + const accountServiceMock = { + archiveChange: new Subject(), + }; + + const eventServiceMock = { + dispatch: jasmine.createSpy('dispatch'), + }; + + const dataServiceMock = { + setCurrentFolder: jasmine.createSpy('setCurrentFolder'), + folderUpdate: of(new FolderVO({})), + selectedItems$: jasmine + .createSpy('selectedItems$') + .and.returnValue(of(new Set())), + multiSelectChange: of(true), + itemToShow$: jasmine.createSpy('itemToShow$').and.returnValue(of(null)), + fetchLeanItems: jasmine.createSpy('fetchLeanItems'), + onSelectEvent: jasmine.createSpy('onSelectEvent'), + }; + + const routerMock = { + navigate: jasmine.createSpy('navigate'), + events: new Subject(), + url: '/folder', + }; + + const routeMock = { + snapshot: { + data: { + currentFolder: new FolderVO({ + type: 'type.folder.root', + }), + showSidebar: true, + fileListCentered: false, + isPublicArchive: false, + }, + queryParamMap: { + has: jasmine.createSpy('has').and.returnValue(false), + get: jasmine.createSpy('get').and.returnValue(null), + }, + }, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterTestingModule, NoopAnimationsModule], + declarations: [FileListV2Component, FileListItemComponent, CdkPortal], + providers: [ + { provide: DataService, useValue: dataServiceMock }, + { provide: AccountService, useValue: accountServiceMock }, + { provide: ActivatedRoute, useValue: routeMock }, + { provide: Router, useValue: routerMock }, + { provide: Location, useValue: {} }, // Use empty mock for Location + { provide: FolderViewService, useValue: folderViewServiceMock }, + { provide: DragService, useValue: dragServiceMock }, + { provide: DeviceService, useValue: deviceServiceMock }, + { provide: EventService, useValue: eventServiceMock }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FileListV2Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/file-browser/components/file-list-v2/file-list-v2.component.ts b/src/app/file-browser/components/file-list-v2/file-list-v2.component.ts new file mode 100644 index 000000000..edc61c56b --- /dev/null +++ b/src/app/file-browser/components/file-list-v2/file-list-v2.component.ts @@ -0,0 +1,586 @@ +/* @format */ +import { + Component, + Inject, + OnInit, + AfterViewInit, + ElementRef, + EventEmitter, + QueryList, + ViewChildren, + HostListener, + OnDestroy, + HostBinding, + Input, + Optional, + Output, + ViewChild, + NgZone, + Renderer2, + signal, +} from '@angular/core'; +import { DOCUMENT, Location } from '@angular/common'; +import { ActivatedRoute, Router, NavigationEnd } from '@angular/router'; +import { UP_ARROW, DOWN_ARROW } from '@angular/cdk/keycodes'; + +import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; +import { throttle, debounce, find } from 'lodash'; +import { gsap } from 'gsap'; + +import { + FileListItemComponent, + FileListItemVisibleEvent, +} from '@fileBrowser/components/file-list-item/file-list-item.component'; +import { + DataService, + SelectClickEvent, + SelectedItemsSet, + SelectKeyEvent, +} from '@shared/services/data/data.service'; +import { FolderVO } from '@models/folder-vo'; +import { RecordVO, ItemVO } from '@root/app/models'; +import { FolderView } from '@shared/services/folder-view/folder-view.enum'; +import { FolderViewService } from '@shared/services/folder-view/folder-view.service'; +import { + HasSubscriptions, + unsubscribeAll, +} from '@shared/utilities/hasSubscriptions'; +import { + slideUpAnimation, + ngIfScaleAnimationDynamic, +} from '@shared/animations'; +import { DragService } from '@shared/services/drag/drag.service'; +import { DeviceService } from '@shared/services/device/device.service'; +import debug from 'debug'; +import { CdkPortal } from '@angular/cdk/portal'; +import { AccountService } from '@shared/services/account/account.service'; +import { routeHasDialog } from '@shared/utilities/router'; +import { RouteHistoryService } from '@root/app/route-history/route-history.service'; +import { EventService } from '@shared/services/event/event.service'; +import { Dialog } from '@angular/cdk/dialog'; +import { CreateAccountDialogComponent } from '@share-preview/components/create-account-dialog/create-account-dialog.component'; + +export interface ItemClickEvent { + event?: MouseEvent; + item: RecordVO | FolderVO; + selectable?: boolean; +} + +export interface FileListItemParent { + onItemClick(itemClick: ItemClickEvent); +} +const VISIBLE_DEBOUNCE = 250; + +const DRAG_SCROLL_THRESHOLD = 100; // px from top or bottom +const DRAG_SCROLL_STEP = 20; +@Component({ + selector: 'pr-file-list-v2', + templateUrl: './file-list-v2.component.html', + styleUrls: ['./file-list-v2.component.scss'], + animations: [slideUpAnimation, ngIfScaleAnimationDynamic], +}) +export class FileListV2Component + implements + OnInit, + AfterViewInit, + OnDestroy, + HasSubscriptions, + FileListItemParent +{ + @ViewChildren(FileListItemComponent) + listItemsQuery: QueryList; + + currentFolder: FolderVO; + listItems: FileListItemComponent[] = []; + + folderView = FolderView.List; + @HostBinding('class.grid-view') inGridView = false; + @HostBinding('class.no-padding') noFileListPadding = false; + @HostBinding('class.show-sidebar') showSidebar = false; + @HostBinding('class.file-list-centered') fileListCentered = false; + showFolderDescription = false; + isRootFolder = false; + + public showFolderThumbnails = false; + + @Input() allowNavigation = true; + + @Output() itemClicked = new EventEmitter(); + + private visibleItemsHandlerDebounced: Function; + private mouseMoveHandlerThrottled: Function; + + shareAccount = signal<{ + fullName?: string; + name?: string; + }>({}); + + private parentData = this.route.parent?.snapshot.data; + private sharePreviewVO = + this.parentData?.sharePreviewItem || this.parentData?.SharePreviewVO; + + createAccountDialogIsOpen = signal(false); + + private reinit = false; + private inFileView = false; + private inDialog = false; + + @ViewChild('scroll') private scrollElement: ElementRef; + visibleItems: Set = new Set(); + + @ViewChild(CdkPortal) portal: CdkPortal; + + private isDraggingInProgress = false; + isDraggingFile = false; + + isMultiSelectEnabled = false; + isMultiSelectEnabledSubscription: Subscription; + + isSorting = false; + + selectedItems: SelectedItemsSet = new Set(); + + subscriptions: Subscription[] = []; + + private debug = debug('component:fileList'); + private unlistenMouseMove: Function; + + constructor( + private account: AccountService, + private route: ActivatedRoute, + private dataService: DataService, + private router: Router, + private elementRef: ElementRef, + private folderViewService: FolderViewService, + private routeHistory: RouteHistoryService, + private location: Location, + @Inject(DOCUMENT) private document: any, + @Optional() private drag: DragService, + private renderer: Renderer2, + public device: DeviceService, + private ngZone: NgZone, + private event: EventService, + private dialog: Dialog, + ) { + this.currentFolder = this.route.snapshot.data.sharePreviewItem?.FolderVO; + // this.noFileListPadding = this.route.snapshot.data.noFileListPadding; + this.fileListCentered = this.route.snapshot.data.fileListCentered; + this.showSidebar = this.route.snapshot.data.showSidebar; + + this.shareAccount.set( + this.sharePreviewVO?.shareLinkResponse?.creatorAccount, + ); + + if (this.route.snapshot.data.noFileListNavigation) { + this.allowNavigation = false; + } + + this.showFolderThumbnails = this.route.snapshot.data?.isPublicArchive; + + this.dataService.setCurrentFolder(this.currentFolder); + + // get current app-wide folder view and register for updates + this.folderView = this.folderViewService.folderView; + this.inGridView = this.folderView === FolderView.Grid; + this.folderViewService.viewChange.subscribe((folderView: FolderView) => { + this.setFolderView(folderView); + }); + + this.visibleItemsHandlerDebounced = debounce( + () => this.loadVisibleItems(), + VISIBLE_DEBOUNCE, + ); + + this.registerArchiveChangeHandlers(); + this.registerRouterEventHandlers(); + this.registerDataServiceHandlers(); + this.registerDragServiceHandlers(); + + const isPrivateRoot = + this.currentFolder?.type === 'type.folder.root.private'; + const isPublicRoot = this.currentFolder?.type === 'type.folder.root.public'; + + if (isPrivateRoot) { + this.event.dispatch({ + action: 'open_private_workspace', + entity: 'account', + }); + } + + if (isPublicRoot) { + this.event.dispatch({ + action: 'open_public_workspace', + entity: 'account', + }); + } + } + + registerArchiveChangeHandlers() { + // register for archive change events to reload the root section + this.subscriptions.push( + this.account.archiveChange.subscribe(async (archive) => { + // may be in a subfolder we don't have access to, reload just the 'root' + const url = this.router.url; + const urlParts = url.split('/').slice(0, 3); + const currentRoot = urlParts.join('/'); + if (currentRoot !== url) { + // this.router.navigateByUrl(currentRoot); + } else { + const timestamp = Date.now(); + const queryParams: any = {}; + queryParams[timestamp] = ''; + // this.router.navigate(['.'], { queryParams, relativeTo: this.route }); + } + }), + ); + } + + registerRouterEventHandlers() { + // register for navigation events to reinit page on folder changes + this.subscriptions.push( + this.router.events + .pipe(filter((event) => event instanceof NavigationEnd)) + .subscribe((event: NavigationEnd) => { + if (event.url.includes('record')) { + this.inFileView = true; + } + + if (routeHasDialog(event)) { + this.inDialog = true; + } + + if (this.reinit && !this.inFileView && !this.inDialog) { + this.refreshView(); + } + + if (!event.url.includes('record') && this.inFileView) { + this.inFileView = false; + } + + if (!routeHasDialog(event) && this.inDialog) { + this.inDialog = false; + } + }), + ); + } + + registerDataServiceHandlers() { + // register for folder update events + this.subscriptions.push( + this.dataService.folderUpdate.subscribe((folder: FolderVO) => { + setTimeout(() => { + this.loadVisibleItems(); + }, 500); + }), + ); + + // register for multi select events + this.subscriptions.push( + this.dataService.multiSelectChange.subscribe((enabled) => { + this.isMultiSelectEnabled = enabled; + }), + ); + + // register for select events + this.subscriptions.push( + this.dataService.selectedItems$().subscribe((selectedItems) => { + this.selectedItems = selectedItems; + }), + ); + + // register for 'show item' events + this.subscriptions.push( + this.dataService.itemToShow$().subscribe((item) => { + setTimeout(() => { + this.scrollToItem(item); + }); + }), + ); + } + + registerDragServiceHandlers() { + // register for drag events to scroll if needed + if (this.drag) { + this.subscriptions.push( + this.drag.events().subscribe((dragEvent) => { + switch (dragEvent.type) { + case 'start': + case 'end': + this.isDraggingInProgress = dragEvent.type === 'start'; + break; + } + }), + ); + + this.mouseMoveHandlerThrottled = throttle((event: MouseEvent) => { + this.checkDragScrolling(event); + }, 64); + } + } + + registerMouseMoveHandler() { + if (!this.unlistenMouseMove) { + this.ngZone.runOutsideAngular(() => { + this.unlistenMouseMove = this.renderer.listen( + this.scrollElement.nativeElement, + 'mousemove', + (event) => this.onViewportMouseMove(event), + ); + }); + } + } + + refreshView() { + this.ngOnInit(); + setTimeout(() => { + this.ngAfterViewInit(); + }, 1); + } + + ngOnInit() { + this.currentFolder = this.route.snapshot.data.sharePreviewItem?.FolderVO; + this.showSidebar = this.route.snapshot.data.showSidebar; + this.dataService.setCurrentFolder(this.currentFolder); + + // this.isRootFolder = this.currentFolder.type?.includes('root'); + this.showFolderDescription = this.route.snapshot.data.showFolderDescription; + + this.visibleItems.clear(); + this.reinit = true; + } + + ngAfterViewInit() { + if (this.listItemsQuery) { + this.listItems = this.listItemsQuery.toArray(); + } + + this.registerMouseMoveHandler(); + + this.loadVisibleItems(true); + + if (this.showSidebar) { + this.getScrollElement().scrollTo(0, 0); + } + + const queryParams = this.route.snapshot.queryParamMap; + if (queryParams.has('showItem')) { + const folder_linkId = Number(queryParams.get('showItem')); + this.location.replaceState(this.router.url.split('?')[0]); + const item = find(this.currentFolder?.ChildItemVOs, { folder_linkId }); + this.scrollToItem(item); + if (!this.device.isMobileWidth()) { + setTimeout(() => { + this.dataService.onSelectEvent({ + type: 'click', + item, + }); + }); + } + } + } + + ngOnDestroy() { + // this.dataService.setCurrentFolder(); + unsubscribeAll(this.subscriptions); + if (this.unlistenMouseMove) { + this.unlistenMouseMove(); + } + } + + setFolderView(folderView: FolderView) { + this.folderView = folderView; + this.inGridView = folderView === FolderView.Grid; + } + + getScrollElement(): HTMLElement { + return ( + this.device.isMobileWidth() || !this.showSidebar + ? this.document.documentElement + : this.scrollElement.nativeElement + ) as HTMLElement; + } + + onViewportMouseMove(event: MouseEvent) { + if (this.isDraggingInProgress && this.mouseMoveHandlerThrottled) { + this.mouseMoveHandlerThrottled(event); + } + } + + scrollToItem(item: ItemVO) { + this.debug('scroll to item %o', item); + const folder_linkId = item.folder_linkId; + const listItem = find( + this.listItemsQuery.toArray(), + (x) => x.item.folder_linkId === folder_linkId, + ); + if (listItem) { + const itemElem = listItem.element.nativeElement as HTMLElement; + itemElem.scrollIntoView(); + } + } + + checkDragScrolling(event: MouseEvent) { + const scrollElem = this.scrollElement.nativeElement as HTMLElement; + const bounds = scrollElem.getBoundingClientRect(); + const top = bounds.top; + const bottom = top + bounds.height; + const currentScrollTop = scrollElem.scrollTop; + const currentScrollHeight = scrollElem.scrollHeight; + const maxScrollTop = currentScrollHeight - bounds.height; + + if (top < event.clientY && event.clientY < top + DRAG_SCROLL_THRESHOLD) { + if (currentScrollTop > 0) { + let step = DRAG_SCROLL_STEP; + if (event.clientY < top + DRAG_SCROLL_THRESHOLD / 2) { + step = step * 3; + } + scrollElem.scrollBy({ left: 0, top: -step, behavior: 'smooth' }); + if (scrollElem.scrollTop > 0) { + this.mouseMoveHandlerThrottled(event); + } + } + } else if ( + bottom > event.clientY && + event.clientY > bottom - DRAG_SCROLL_THRESHOLD + ) { + if (currentScrollTop < maxScrollTop) { + let step = DRAG_SCROLL_STEP; + if (event.clientY > maxScrollTop - DRAG_SCROLL_THRESHOLD / 2) { + step = step * 3; + } + scrollElem.scrollBy({ left: 0, top: step, behavior: 'smooth' }); + if (scrollElem.scrollTop < maxScrollTop) { + this.mouseMoveHandlerThrottled(event); + } + } + } + } + + onItemClick(itemClick: ItemClickEvent) { + const needsAccountDialog = + !this.account.getAccount() && + this.sharePreviewVO?.shareLinkResponse?.accessRestrictions === 'none'; + + if (needsAccountDialog) { + this.dialog.open(CreateAccountDialogComponent, { + data: { + sharerName: this.shareAccount().name, + }, + }); + } + this.itemClicked.emit(itemClick); + + if (!this.showSidebar || !itemClick.selectable) { + return; + } + + const selectEvent: SelectClickEvent = { + type: 'click', + item: itemClick.item, + }; + + if (itemClick.event?.shiftKey) { + selectEvent.modifierKey = 'shift'; + } else if (itemClick.event?.metaKey || itemClick.event?.ctrlKey) { + selectEvent.modifierKey = 'ctrl'; + } + + this.dataService.onSelectEvent(selectEvent); + } + + onSort(isSorting: boolean) { + this.isSorting = isSorting; + } + + @HostListener('window:keydown', ['$event']) + onWindowKeydown(event: KeyboardEvent) { + if (this.checkKeyEvent(event)) { + if (event.keyCode === UP_ARROW || event.keyCode === DOWN_ARROW) { + event.preventDefault(); + const selectEvent: SelectKeyEvent = { + type: 'key', + key: event.keyCode === UP_ARROW ? 'up' : 'down', + }; + + if (event.shiftKey) { + selectEvent.modifierKey = 'shift'; + } + + this.dataService.onSelectEvent(selectEvent); + } + } + } + + @HostListener('window:keydown.control.a', ['$event']) + @HostListener('window:keydown.meta.a', ['$event']) + onSelectAllKeypress(event: KeyboardEvent) { + if (this.checkKeyEvent(event)) { + event.preventDefault(); + const selectEvent: SelectKeyEvent = { + type: 'key', + key: 'a', + modifierKey: 'ctrl', + }; + + this.dataService.onSelectEvent(selectEvent); + } + } + + checkKeyEvent(event: KeyboardEvent) { + return ( + event.target === this.document.body && !this.router.url.includes('record') + ); + } + + showCreateAccountDialog() { + if (!this.createAccountDialogIsOpen()) { + const dialogRef = this.dialog.open(CreateAccountDialogComponent, { + data: { + sharerName: this.shareAccount().fullName || this.shareAccount().name, + }, + }); + dialogRef.closed?.subscribe(() => { + this.createAccountDialogIsOpen.set(false); + }); + + this.createAccountDialogIsOpen.set(true); + } + } + + async loadVisibleItems(animate?: boolean) { + this.debug('loadVisibleItems %d items', this.visibleItems.size); + if (!this.visibleItems.size) { + return; + } + + const visibleListItems = Array.from(this.visibleItems); + this.visibleItems.clear(); + if (animate) { + const targetElems = visibleListItems.map((c) => c.element.nativeElement); + gsap.from(targetElems, 0.25, { + duration: 0.25, + opacity: 0, + ease: 'Power4.easeOut', + stagger: { + amount: 0.015, + }, + }); + } + + const itemsToFetch = visibleListItems.map((c) => c.item); + + if (itemsToFetch.length) { + await this.dataService.fetchLeanItems(itemsToFetch); + } + } + + onItemVisible(event: FileListItemVisibleEvent) { + if (event.visible) { + this.visibleItems.add(event.component); + this.visibleItemsHandlerDebounced(); + } else { + this.visibleItems.delete(event.component); + } + } +} diff --git a/src/app/file-browser/components/file-list/file-list.component.ts b/src/app/file-browser/components/file-list/file-list.component.ts index c215cb7ec..ef4078e5a 100644 --- a/src/app/file-browser/components/file-list/file-list.component.ts +++ b/src/app/file-browser/components/file-list/file-list.component.ts @@ -362,7 +362,6 @@ export class FileListComponent } ngOnDestroy() { - this.dataService.setCurrentFolder(); unsubscribeAll(this.subscriptions); if (this.unlistenMouseMove) { this.unlistenMouseMove(); diff --git a/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.html b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.html new file mode 100644 index 000000000..a09c4a393 --- /dev/null +++ b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.html @@ -0,0 +1,194 @@ + +
+
+ Back +
+
+
+
+
+ + + + + +
+
+
+ +
+
+ +
+
+
{{ currentRecord?.displayName }}
+ +
diff --git a/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.scss b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.scss new file mode 100644 index 000000000..f221cefec --- /dev/null +++ b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.scss @@ -0,0 +1,311 @@ +@import 'variables'; + +$toolbar-height: 30px; +$transition-length: 0.35s; +$button-size: 2rem; +:host { + @include fullscreenComponent; +} + +.file-viewer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: white; + + -webkit-transform: translate3d(0, 0, 0); + -moz-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + + &.minimal { + background-color: black; + .file-viewer-close { + transform: translateY(-100%); + } + + .file-viewer-name { + transform: translateY(100%); + } + } + + transition: background-color $transition-length ease-in-out; +} + +.file-viewer-close { + position: absolute; + top: 0; + height: $toolbar-height; + transform: translateY(0); + display: flex; + align-items: center; + + a { + line-height: $toolbar-height; + padding: 0px 5px; + cursor: pointer; + > span { + opacity: 0.5; + } + } + + button.close { + border-radius: 50%; + background-color: $gray-500; + width: $button-size; + height: $button-size; + line-height: $button-size; + display: flex; + justify-content: center; + align-items: center; + } +} + +.file-viewer-name { + position: absolute; + bottom: 0; + left: 0; + right: 0; + white-space: nowrap; + height: $toolbar-height; + line-height: $toolbar-height; + text-align: center; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + transform: translateY(0); +} + +.file-viewer-name, +.file-viewer-close { + transition: transform $transition-length ease-in-out; +} + +.file-viewer-image, +pr-zooming-image-viewer, +pr-thumbnail, +pr-video, +pr-audio .thumb-preview, +iframe { + position: absolute; + top: 0px; + bottom: 0px; + left: 5px; + right: 5px; + background-size: contain; + background-repeat: no-repeat; + background-position: 50% 50%; + + &.full { + z-index: 1; + } +} + +.thumb-target { + position: absolute; + top: $toolbar-height; + bottom: $toolbar-height; + left: 0px; + right: 0px; + + iframe { + width: 100%; + height: 100%; + + @media screen and (max-width: 800px) { + display: none; + } + } +} + +.file-viewer-control { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: $button-size; + height: $button-size; + border-radius: 50%; + background: rgba($gray-500, 0.5); + text-align: center; + font-size: 24px; + font-weight: bold; + z-index: 2; + color: white; + cursor: pointer; + display: flex; + + align-items: center; + justify-content: center; + + span { + position: relative; + top: -2px; + } + + @media screen and (max-width: 800px) { + display: none; + } + + &.file-viewer-control-previous { + left: 10px; + } + + &.file-viewer-control-next { + right: 10px; + } +} + +.thumb-wrapper { + position: absolute; + top: 0px; + bottom: 0px; + left: 0px; + right: 0px; + + &.prev { + transform: translateX(-100%); + z-index: 1; + } + + &.next { + transform: translateX(100%); + z-index: 1; + } + + &.current { + z-index: 2; + } +} + +.file-viewer-metadata, +.file-viewer-metadata-wrapper { + display: none; +} + +.editing-date pr-inline-value-edit[type='date'] { + left: -20%; + top: 3em; + margin-bottom: 3em; +} + +.can-edit { + pr-tags, + .location-container { + cursor: pointer; + } +} + +// Desktop tweaks +@media screen and (min-width: 801px) { + .thumb-target.thumb-target { + right: $metadata-width; + overflow: hidden; + } + + .file-viewer-metadata-wrapper { + top: $toolbar-height; + bottom: $toolbar-height; + width: $metadata-width; + right: 0; + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + padding: 10px; + overflow-y: auto; + overflow-x: hidden; + + td:first-child { + font-weight: bold; + } + + .metadata-table { + width: calc($metadata-width - 20px); + } + } + + .file-viewer-metadata { + display: flex; + flex-direction: column; + align-items: stretch; + margin: auto 0; + } + + .file-viewer-name { + display: none; + } +} + +.file-viewer-metadata { + td { + padding: 5px 0; + vertical-align: top; + + &:first-child { + padding-right: 15px; + vertical-align: middle; + + &.top-align { + vertical-align: top; + padding-top: 0.5rem; + } + } + } + + .file-viewer-description { + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; + } + .metadata-item { + padding: 5px 10px; + label { + font-weight: 600; + margin-bottom: 0px; + } + + .metadata-item-content { + $padding-y: 0.25rem; + padding: $padding-y 0; + min-height: $line-height-base * 1rem + (2 * $padding-y); + } + } +} + +@supports (padding-left: env(safe-area-inset-left)) { + .file-viewer { + padding-top: env(safe-area-inset-top); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + } + + .file-viewer-name { + bottom: env(safe-area-inset-bottom); + } + + .thumb-target { + --safe-area-inset-top: env(safe-area-inset-top); + --safe-area-inset-bottom: env(safe-area-inset-bottom); + top: calc(var(--safe-area-inset-top) + 30px); + bottom: calc(var(--safe-area-inset-bottom) + 30px); + left: env(safe-area-inset-left); + right: env(safe-area-inset-right); + } +} + +pr-download-button { + margin-top: 1rem; +} + +.title { + width: 110px; +} + +.alt-text { + font-weight: bold; + margin-top: 10px; +} diff --git a/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.spec.ts b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.spec.ts new file mode 100644 index 000000000..ddb1d8c9a --- /dev/null +++ b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.spec.ts @@ -0,0 +1,422 @@ +/* @format */ +import { Router, ActivatedRoute } from '@angular/router'; +import { SecurityContext } from '@angular/core'; +import { Shallow } from 'shallow-render'; +import { Subject } from 'rxjs'; + +import { + RecordVO, + ItemVO, + TagVOData, + ArchiveVO, + Record, + Tag, +} from '@root/app/models'; +import { AccountService } from '@shared/services/account/account.service'; +import { DataService } from '@shared/services/data/data.service'; +import { EditService } from '@core/services/edit/edit.service'; +import { TagsService } from '@core/services/tags/tags.service'; +import { PublicProfileService } from '@public/services/public-profile/public-profile.service'; +import { FileBrowserComponentsModule } from '../../file-browser-components.module'; +import { TagsComponent } from '../../../shared/components/tags/tags.component'; +import { FileViewerV2Component } from './file-viewer-v2.component'; + +const defaultTagList: Tag[] = [ + { + id: '1', + name: 'tagOne', + type: 'type.generic.placeholder', + }, + { + id: '2', + name: 'tagTwo', + type: 'type.generic.placeholder', + }, + { + id: '3', + name: 'customField:customValueOne', + type: 'type.tag.metadata.customField', + }, + { + id: '4', + name: 'customField:customValueTwo', + type: 'type.tag.metadata.customField', + }, +]; +const defaultItem = { + displayName: 'Default Item', + tags: defaultTagList, + type: 'document', + folderLinkId: '0', + folder_linkId: '0', +}; +const secondItem = { + displayName: 'Second Item', + tags: [], + type: 'image', + folderLinkId: '1', + folder_linkId: '1', +} as unknown; + +interface ActivatedRouteSnapshotData { + singleFile: boolean; + isPublicArchive: boolean; + currentRecord: Record; +} + +class MockTagsService { + public itemTagsObservable = new Subject(); + public getItemTags$() { + return this.itemTagsObservable; + } +} + +describe('FileViewerV2Component', () => { + let shallow: Shallow; + let activatedRouteData: ActivatedRouteSnapshotData; + let folderChildren: Record[]; + let tagsService: MockTagsService; + let navigatedUrl: string[]; + let savedProperty: { name: string; value: any }; + let hasAccess: boolean; + let openedDialogs: string[]; + let downloaded: boolean; + async function defaultRender() { + return await shallow.render(``); + } + + function setUpMultipleRecords(...items: Record[] | RecordVO[]) { + folderChildren.push(...(items as any)); + activatedRouteData.singleFile = false; + } + + beforeEach(async () => { + navigatedUrl = []; + activatedRouteData = { + singleFile: true, + isPublicArchive: true, + currentRecord: defaultItem, + }; + folderChildren = []; + tagsService = new MockTagsService(); + savedProperty = undefined; + hasAccess = true; + openedDialogs = []; + downloaded = false; + shallow = new Shallow(FileViewerV2Component, FileBrowserComponentsModule) + .dontMock(TagsService) + .dontMock(PublicProfileService) + .mock(Router, { + navigate: (route: string[]) => { + navigatedUrl = route; + return Promise.resolve(true); + }, + routerState: { + snapshot: { + url: 'exampleUrl.com', + }, + }, + }) + .mock(ActivatedRoute, { + snapshot: { + data: activatedRouteData, + }, + }) + .mock(DataService, { + currentFolder: { + ChildItemVOs: folderChildren as unknown as ItemVO[], + }, + fetchFullItems: async () => {}, + fetchLeanItems: async () => {}, + async downloadFileV2(_item: Record, _type: string) { + downloaded = true; + }, + }) + .mock(AccountService, { + checkMinimumAccess: (_itemAccessRole, _minimumAccess) => hasAccess, + }) + .mock(EditService, { + async saveItemVoProperty(_record, name, value) { + savedProperty = { name: name as string, value }; + }, + async openLocationDialog(_item) { + openedDialogs.push('location'); + }, + async openTagsDialog(_record, _type) { + openedDialogs.push('tags'); + }, + }) + .provide({ provide: TagsService, useValue: tagsService }) + .provide({ + provide: PublicProfileService, + useValue: new PublicProfileService(), + }); + }); + + it('should create', async () => { + const { element } = await defaultRender(); + + expect(element).not.toBeNull(); + }); + + it('should have two tags components', async () => { + const { findComponent } = await defaultRender(); + + expect(findComponent(TagsComponent)).toHaveFound(2); + }); + + it('should correctly distinguish between keywords and custom metadata', async () => { + const { element } = await defaultRender(); + + expect( + element.componentInstance.keywords.find((tag) => tag.name === 'tagOne'), + ).toBeTruthy(); + + expect( + element.componentInstance.keywords.find((tag) => tag.name === 'tagTwo'), + ).toBeTruthy(); + + expect( + element.componentInstance.keywords.find( + (tag) => tag.name === 'customField:customValueOne', + ), + ).not.toBeTruthy(); + + expect( + element.componentInstance.keywords.find( + (tag) => tag.name === 'customField:customValueTwo', + ), + ).not.toBeTruthy(); + + expect( + element.componentInstance.customMetadata.find( + (tag) => tag.name === 'tagOne', + ), + ).not.toBeTruthy(); + + expect( + element.componentInstance.customMetadata.find( + (tag) => tag.name === 'tagTwo', + ), + ).not.toBeTruthy(); + + expect( + element.componentInstance.customMetadata.find( + (tag) => tag.name === 'customField:customValueOne', + ), + ).toBeTruthy(); + + expect( + element.componentInstance.customMetadata.find( + (tag) => tag.name === 'customField:customValueTwo', + ), + ).toBeTruthy(); + }); + + it('should be able to load multiple record in a folder', async () => { + setUpMultipleRecords(defaultItem, secondItem); + const { element } = await defaultRender(); + + expect(element).not.toBeNull(); + }); + + it('should listen to tag updates from the tag service', async () => { + const { instance } = await defaultRender(); + tagsService.itemTagsObservable.next([ + { type: 'type.tag.metadata.customField', name: 'test:metadta' }, + { type: 'type.generic.placeholder', name: 'test' }, + ]); + + expect(instance.keywords.length).toBe(1); + expect(instance.customMetadata.length).toBe(1); + }); + + it('should listen to public profile archive updates', async () => { + const { inject, instance } = await defaultRender(); + const publicProfile = inject(PublicProfileService); + publicProfile.archiveBs.next(new ArchiveVO({ allowPublicDownload: true })); + + expect(instance.allowDownloads).toBeTruthy(); + }); + + it('should handle null public profile archive updates', async () => { + const { inject, instance } = await defaultRender(); + const publicProfile = inject(PublicProfileService); + publicProfile.archiveBs.next(new ArchiveVO({ allowPublicDownload: true })); + publicProfile.archiveBs.next(null); + + expect(instance.allowDownloads).toBeFalsy(); + }); + + describe('Keyboard Input', () => { + function keyDown( + instance: FileViewerV2Component, + key: 'ArrowRight' | 'ArrowLeft', + ) { + instance.onKeyDown(new KeyboardEvent('keydown', { key })); + } + + it('should handle left arrow key input', async () => { + setUpMultipleRecords(secondItem, defaultItem); + const { instance } = await defaultRender(); + keyDown(instance, 'ArrowLeft'); + + expect(instance.currentIndex).toBe(0); + }); + + it('does not wrap around on left arrow', async () => { + setUpMultipleRecords(defaultItem, secondItem); + const { instance } = await defaultRender(); + keyDown(instance, 'ArrowLeft'); + + expect(instance.currentIndex).toBe(0); + }); + }); + + describe('URLs of PDF files', () => { + function setUpCurrentRecord( + typeOfOriginal: string, + fileURLOfOriginal: string | false = 'http://example.com/original', + ) { + activatedRouteData.currentRecord = { + type: 'document', + displayName: 'Test Doc', + tags: [], + files: [ + { + format: 'file.format.original', + type: typeOfOriginal, + fileUrl: fileURLOfOriginal, + }, + { + format: 'file.format.converted', + type: 'odt', + fileUrl: 'http://example.com/ignored', + }, + { + format: 'file.format.converted', + type: 'pdf', + fileUrl: 'http://example.com/used', + }, + ], + } as unknown as Record; + } + function expectSantizedUrlToContain( + instance: FileViewerV2Component, + phrase: string, + ) { + const url = instance.getDocumentUrl(); + + expect(url).toBeTruthy(); + expect( + instance.sanitizer.sanitize(SecurityContext.RESOURCE_URL, url), + ).toContain(phrase); + } + it('can get the URL of a document', async () => { + setUpCurrentRecord('doc'); + const { instance } = await defaultRender(); + expectSantizedUrlToContain(instance, 'used'); + }); + + it('will prefer the URL of the original if it is a PDF', async () => { + setUpCurrentRecord('pdf'); + const { instance } = await defaultRender(); + expectSantizedUrlToContain(instance, 'original'); + }); + + it('will prefer the URL of the original if it is a TXT file', async () => { + setUpCurrentRecord('txt'); + const { instance } = await defaultRender(); + expectSantizedUrlToContain(instance, 'original'); + }); + + it('will have a falsy document URL if it is not a document', async () => { + const { instance } = await defaultRender(); + + expect(instance.getDocumentUrl()).toBeFalsy(); + }); + + it('will have a falsy document URL if the URL is falsy', async () => { + setUpCurrentRecord('pdf', false); + const { instance } = await defaultRender(); + + expect(instance.getDocumentUrl()).toBeFalsy(); + }); + }); + + describe('Component API', () => { + function setAccess(access: boolean) { + hasAccess = access; + activatedRouteData.isPublicArchive = false; + } + it('can close the file viewer', async () => { + const { instance } = await defaultRender(); + instance.close(); + + expect(navigatedUrl).toContain('.'); + }); + + it('can finish editing', async () => { + const { instance } = await defaultRender(); + await instance.onFinishEditing('displayName', 'Test'); + + expect(savedProperty.name).toBe('displayName'); + expect(savedProperty.value).toBe('Test'); + }); + + it('can open the location dialog with edit permissions', async () => { + setAccess(true); + const { fixture, instance } = await defaultRender(); + instance.onLocationClick(); + await fixture.whenStable(); + + expect(openedDialogs).toContain('location'); + }); + + it('cannot open the location dialog without edit permissions', async () => { + setAccess(false); + const { fixture, instance } = await defaultRender(); + instance.onLocationClick(); + await fixture.whenStable(); + + expect(openedDialogs).not.toContain('location'); + }); + + it('can open the tags dialog with edit permissions', async () => { + setAccess(true); + const { fixture, instance } = await defaultRender(); + instance.onTagsClick('keyword'); + await fixture.whenStable(); + + expect(openedDialogs).toContain('tags'); + }); + + it('cannot open the tags dialog with edit permissions', async () => { + setAccess(false); + const { fixture, instance } = await defaultRender(); + instance.onTagsClick('keyword'); + await fixture.whenStable(); + + expect(openedDialogs).not.toContain('tags'); + }); + + it('can download items', async () => { + const { fixture, instance } = await defaultRender(); + instance.onDownloadClick(); + await fixture.whenStable(); + + expect(downloaded).toBeTrue(); + }); + + it('should display "Click to add location" on fullscreen view', async () => { + const { fixture, instance, find } = await defaultRender(); + instance.canEdit = true; + await fixture.detectChanges(); + const locationSpan = find('.add-location'); + + expect(locationSpan.nativeElement.textContent.trim()).toBe( + 'Click to add location', + ); + }); + }); +}); diff --git a/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.ts b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.ts new file mode 100644 index 000000000..64b434a01 --- /dev/null +++ b/src/app/file-browser/components/file-viewer-v2/file-viewer-v2.component.ts @@ -0,0 +1,481 @@ +/* @format */ +import { + Component, + OnInit, + OnDestroy, + ElementRef, + Inject, + HostListener, + Optional, +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { + Router, + ActivatedRoute, + ActivatedRouteSnapshot, +} from '@angular/router'; +import { Key } from 'ts-key-enum'; +import * as Hammer from 'hammerjs'; +import { gsap } from 'gsap'; +import { filter, findIndex, find } from 'lodash'; +import { + RecordVO, + ItemVO, + TagVOData, + AccessRole, + Record, +} from '@root/app/models'; +import { AccountService } from '@shared/services/account/account.service'; +import { DataService } from '@shared/services/data/data.service'; +import { EditService } from '@core/services/edit/edit.service'; +import { DataStatus } from '@models/data-status.enum'; +import { DomSanitizer } from '@angular/platform-browser'; +import { PublicProfileService } from '@public/services/public-profile/public-profile.service'; +import type { KeysOfType } from '@shared/utilities/keysoftype'; +import { Subscription } from 'rxjs'; +import { SearchService } from '@search/services/search.service'; +import { ZoomingImageViewerComponent } from '@shared/components/zooming-image-viewer/zooming-image-viewer.component'; +import { FileFormat } from '@models/file-vo'; +import { GetAccessFileV2 } from '@models/get-access-file'; +import { TagsService } from '../../../core/services/tags/tags.service'; + +@Component({ + selector: 'pr-file-viewer-v2', + templateUrl: './file-viewer-v2.component.html', + styleUrls: ['./file-viewer-v2.component.scss'], + providers: [SearchService], +}) +export class FileViewerV2Component implements OnInit, OnDestroy { + // Record + public currentRecord: Record; + public prevRecord: RecordVO; + public nextRecord: RecordVO; + public records: Record[]; + public currentIndex: number; + public isZoomableImage = false; + public isVideo = false; + public isAudio = false; + public isDocument = false; + public showThumbnail = true; + public isPublicArchive: boolean = false; + public allowDownloads: boolean = false; + public keywords: TagVOData[]; + public customMetadata: TagVOData[]; + + public documentUrl = null; + + public canEdit: boolean; + + // Swiping + private touchElement: HTMLElement; + private thumbElement: HTMLElement; + private bodyScroll: number; + private hammer: HammerManager; + private disableSwipes: boolean; + private fullscreen: boolean; + private velocityThreshold = 0.2; + private screenWidth: number; + private offscreenThreshold: number; + private loadingRecord = false; + + // UI + public useMinimalView = false; + public editingDate: boolean = false; + private bodyScrollTop: number; + private tagSubscription: Subscription; + + constructor( + private router: Router, + private route: ActivatedRoute, + private element: ElementRef, + private dataService: DataService, + @Inject(DOCUMENT) private document: any, + public sanitizer: DomSanitizer, + private accountService: AccountService, + private editService: EditService, + private tagsService: TagsService, + private activatedRoute: ActivatedRoute, + @Optional() private publicProfile: PublicProfileService, + ) { + // store current scroll position in file list + this.bodyScrollTop = window.scrollY; + + const resolvedRecord = route.snapshot.data.currentRecord; + + if (route.snapshot.data.singleFile) { + this.currentRecord = resolvedRecord; + this.records = [this.currentRecord]; + this.currentIndex = 0; + } else { + this.records = filter( + this.dataService.currentFolder?.ChildItemVOs || + route.snapshot.data.sharePreviewItem?.FolderVO?.ChildItemVOs, + 'recordId', + ) as unknown as Record[]; + + this.currentIndex = findIndex(this.records, { + folderLinkId: resolvedRecord.folder_linkId?.toString(), + }); + + if (this.currentIndex === -1) { + this.currentIndex = 0; + } + + this.currentRecord = this.records[this.currentIndex]; + + if ( + this.currentRecord && + resolvedRecord && + resolvedRecord !== this.currentRecord + ) { + this.updateRecordInstance(this.currentRecord, resolvedRecord); + } + + this.loadQueuedItems(); + } + + if (route.snapshot.data?.isPublicArchive) { + this.isPublicArchive = route.snapshot.data.isPublicArchive; + } + + if (publicProfile) { + publicProfile.archive$()?.subscribe((archive) => { + this.allowDownloads = !!archive?.allowPublicDownload; + }); + } + + this.canEdit = + this.accountService.checkMinimumAccess( + (this.currentRecord as unknown as RecordVO)?.accessRole, + AccessRole.Editor, + ) && !route.snapshot.data?.isPublicArchive; + + this.tagSubscription = this.tagsService + .getItemTags$() + ?.subscribe((tags) => { + this.customMetadata = tags?.filter( + (tag) => tag?.type.includes('type.tag.metadata'), + ); + this.keywords = tags?.filter( + (tag) => !tag?.type.includes('type.tag.metadata'), + ); + }); + } + + updateRecordInstance(target: any, source: any): void { + if (!target || !source) { + return; + } + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(source, key)) continue; + + const value = source[key]; + + // Skip undefined and functions + if (value === undefined || typeof value === 'function') continue; + + // Handle nested arrays of objects (e.g. tags, files) + if (Array.isArray(value)) { + target[key] = value.map((item) => + typeof item === 'object' ? { ...item } : item, + ); + } + // Handle nested objects + else if (value !== null && typeof value === 'object') { + target[key] = { ...value }; + } + // Primitive assignment + else { + target[key] = value; + } + } + } + + ngOnInit() { + this.initRecord(); + + // disable scrolling file list in background + this.document.body.style.setProperty('overflow', 'hidden'); + + // bind hammer events to thumbnail area + this.touchElement = + this.element.nativeElement.querySelector('.thumb-target'); + this.hammer = new Hammer(this.touchElement); + this.hammer.on('pan', (evt: HammerInput) => { + this.handlePanEvent(evt); + }); + // this.hammer.on('tap', (evt: HammerInput) => { + // this.useMinimalView = !this.useMinimalView; + // }); + + this.screenWidth = this.touchElement.clientWidth; + this.offscreenThreshold = this.screenWidth / 2; + } + + ngOnDestroy() { + // re-enable scrolling and return to initial scroll position + this.document.body.style.setProperty('overflow', ''); + setTimeout(() => { + window.scrollTo(0, this.bodyScrollTop); + }); + this.tagSubscription.unsubscribe(); + } + + @HostListener('window:resize', []) + onViewportResize(event) { + this.screenWidth = this.touchElement.clientWidth; + this.offscreenThreshold = this.screenWidth / 2; + } + + // Keyboard + @HostListener('document:keydown', ['$event']) + onKeyDown(event) { + if (!this.fullscreen) { + switch (event.key) { + case Key.ArrowLeft: + this.incrementCurrentRecord(true); + break; + case Key.ArrowRight: + this.incrementCurrentRecord(); + break; + } + } + } + + initRecord() { + this.isAudio = this.currentRecord?.type.includes('audio'); + this.isVideo = this.currentRecord?.type.includes('video'); + this.isZoomableImage = + this.currentRecord?.type.includes('image') && + this.currentRecord?.files?.length && + typeof ZoomingImageViewerComponent.chooseFullSizeImageV2( + this.currentRecord, + ) !== 'undefined'; + this.isDocument = this.currentRecord?.files?.some( + (obj) => obj.type.includes('pdf') || obj.type.includes('txt'), + ); + this.documentUrl = this.getDocumentUrl(); + this.setCurrentTags(); + } + + toggleSwipe(value: boolean) { + this.disableSwipes = value; + } + + toggleFullscreen(value: boolean) { + this.fullscreen = value; + } + + getDocumentUrl() { + if (!this.isDocument) { + return false; + } + + const original = this.currentRecord.files.find( + (file) => file.format === FileFormat.Original, + ); + const access = GetAccessFileV2(this.currentRecord); + + let url; + + if (original?.type.includes('pdf') || original?.type.includes('txt')) { + url = original?.fileUrl; + } else if (access) { + url = access?.fileUrl; + } + + if (!url) { + return false; + } + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + } + + isQueued(indexToCheck: number) { + return ( + indexToCheck >= this.currentIndex - 1 && + indexToCheck <= this.currentIndex + 1 + ); + } + + handlePanEvent(evt: HammerInput) { + if (this.disableSwipes) { + return; + } + + const queuedThumbs = document.querySelectorAll('.thumb-wrapper.queue'); + + const previous = evt.deltaX > 0; + const next = evt.deltaX < 0; + const canNavigate = + (previous && this.records[this.currentIndex - 1]) || + (next && this.records[this.currentIndex + 1]); + const fastEnough = Math.abs(evt.velocityX) > this.velocityThreshold; + const farEnough = Math.abs(evt.deltaX) > this.offscreenThreshold; + + if (!evt.isFinal) { + // follow pointer for panning + gsap.set(queuedThumbs, { + x: (index, target) => { + return evt.deltaX + getOrder(target) * this.screenWidth; + }, + }); + } else if (!(fastEnough || farEnough) || !canNavigate) { + // reset to center, not fast enough or far enough + gsap.to(queuedThumbs, { + duration: 0.5, + x: (index, target) => { + return getOrder(target) * this.screenWidth; + }, + ease: 'Power4.easeOut', + } as any); + } else { + // send offscreen to left or right, depending on direction + let offset = 1; + if (evt.deltaX < 0) { + offset = -1; + } + this.disableSwipes = true; + gsap.to(queuedThumbs, { + duration: 0.5, + x: (index, target) => { + return (getOrder(target) + offset) * this.screenWidth; + }, + ease: 'Power4.easeOut', + onComplete: () => { + this.incrementCurrentRecord(previous); + }, + } as any); + } + + function getOrder(elem: HTMLElement) { + if (elem.classList.contains('prev')) { + return -1; + } + if (elem.classList.contains('next')) { + return 1; + } else { + return 0; + } + } + } + + incrementCurrentRecord(previous = false) { + if (this.loadingRecord) { + return; + } + + let targetIndex = this.currentIndex; + if (previous) { + targetIndex--; + } else { + targetIndex++; + } + + if (!this.records[targetIndex]) { + return; + } + + this.loadingRecord = true; + + // update current record and fetch surrounding items + const targetRecord = this.records[targetIndex]; + + this.currentIndex = targetIndex; + this.currentRecord = targetRecord; + + this.initRecord(); + + this.disableSwipes = false; + this.loadQueuedItems(); + + if (targetRecord.archiveNumber) { + this.navigateToCurrentRecord(targetIndex); + } else if ((targetRecord as unknown as RecordVO).isFetching) { + (targetRecord as unknown as RecordVO).fetched.then(() => { + this.navigateToCurrentRecord(targetIndex); + }); + } else { + this.dataService + .fetchLeanItems([targetRecord as unknown as ItemVO]) + .then(() => { + this.navigateToCurrentRecord(targetIndex); + }); + } + } + + navigateToCurrentRecord(index = 0) { + const record = this.records[index]; + this.router.navigate(['../', record.recordId], { + relativeTo: this.route, + }); + this.loadingRecord = false; + } + + loadQueuedItems() { + const surroundingCount = 5; + const start = Math.max(this.currentIndex - surroundingCount, 0); + const end = Math.min( + this.currentIndex + surroundingCount + 1, + this.records.length, + ); + const itemsToFetch = this.records + .slice(start, end) + .filter( + (item: Record) => item.dataStatus < DataStatus.Full, + ) as unknown as ItemVO[]; + if (itemsToFetch.length) { + this.dataService.fetchFullItems(itemsToFetch); + } + } + + close() { + this.router.navigate(['.'], { + relativeTo: this.route.parent, + }); + } + + public async onFinishEditing( + property: KeysOfType, + value: string, + ): Promise { + this.editService.saveItemVoProperty( + this.currentRecord as unknown as ItemVO, + property, + value, + ); + } + + public onLocationClick(): void { + if (this.canEdit) { + this.editService.openLocationDialog( + this.currentRecord as unknown as ItemVO, + ); + } + } + + public onTagsClick(type: string): void { + if (this.canEdit) { + this.editService.openTagsDialog( + this.currentRecord as unknown as ItemVO, + type, + ); + } + } + + public onDateToggle(active: boolean): void { + this.editingDate = active; + } + + public onDownloadClick(): void { + this.dataService.downloadFileV2(this.currentRecord); + } + + private setCurrentTags(): void { + this.keywords = ( + (this.currentRecord as unknown as Record)?.tags || [] + ).filter((tag) => !tag?.type.includes('type.tag.metadata')); + this.customMetadata = ( + (this.currentRecord as unknown as Record)?.tags || [] + ).filter((tag) => tag?.type.includes('type.tag.metadata')); + } +} diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.ts b/src/app/file-browser/components/file-viewer/file-viewer.component.ts index c8c0f89a5..b2feac422 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.ts +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.ts @@ -97,7 +97,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.currentIndex = 0; } else { this.records = filter( - this.dataService.currentFolder.ChildItemVOs, + this.dataService.currentFolder?.ChildItemVOs, 'isRecord', ) as RecordVO[]; this.currentIndex = findIndex(this.records, { diff --git a/src/app/file-browser/components/folder-description/folder-description.component.html b/src/app/file-browser/components/folder-description/folder-description.component.html index 5e7fc348a..c65b0be1d 100644 --- a/src/app/file-browser/components/folder-description/folder-description.component.html +++ b/src/app/file-browser/components/folder-description/folder-description.component.html @@ -1,4 +1,4 @@ - +
{ @@ -123,14 +117,10 @@ export class ShareLinkSettingsComponent implements OnInit { } ngOnInit(): void { - const accountId = this.account.getAccount().accountId; - const archiveNbr = this.account.getArchive().archiveNbr; - const shareLinkUrlPayload = { itemType: this.shareLinkResponse?.itemType, token: this.shareLinkResponse?.token, itemId: this.shareLinkResponse?.itemId, - accountId, }; this.shareLink = shareUrlBuilder(shareLinkUrlPayload); diff --git a/src/app/file-browser/file-browser-components.module.ts b/src/app/file-browser/file-browser-components.module.ts index 63aa43b2a..aa2dfccce 100644 --- a/src/app/file-browser/file-browser-components.module.ts +++ b/src/app/file-browser/file-browser-components.module.ts @@ -29,6 +29,8 @@ import { ShareLinkDropdownComponent } from './components/share-link-dropdown/sha import { DownloadButtonComponent } from './components/download-button/download-button.component'; import { ShareLinkSettingsComponent } from './components/share-link-settings/share-link-settings.component'; +import { FileViewerV2Component } from './components/file-viewer-v2/file-viewer-v2.component'; +import { FileListV2Component } from './components/file-list-v2/file-list-v2.component'; @NgModule({ imports: [ @@ -41,8 +43,10 @@ import { ShareLinkSettingsComponent } from './components/share-link-settings/sha ], exports: [ FileListComponent, + FileListV2Component, FileListItemComponent, FileViewerComponent, + FileViewerV2Component, VideoComponent, SidebarComponent, FileListControlsComponent, @@ -54,9 +58,11 @@ import { ShareLinkSettingsComponent } from './components/share-link-settings/sha ], declarations: [ FileListComponent, + FileListV2Component, FileListItemComponent, FileListControlsComponent, FileViewerComponent, + FileViewerV2Component, FolderViewComponent, VideoComponent, SharingComponent, diff --git a/src/app/file-browser/file-browser.module.ts b/src/app/file-browser/file-browser.module.ts index 9ed284c38..7509a5470 100644 --- a/src/app/file-browser/file-browser.module.ts +++ b/src/app/file-browser/file-browser.module.ts @@ -1,7 +1,11 @@ /* @format */ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterModule } from '@angular/router'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + RouterModule, +} from '@angular/router'; import { FileBrowserRoutingModule } from '@fileBrowser/file-browser.routes'; import { SharedModule } from '@shared/shared.module'; @@ -11,6 +15,7 @@ import { FileListItemComponent } from '@fileBrowser/components/file-list-item/fi import { FileViewerComponent } from '@fileBrowser/components/file-viewer/file-viewer.component'; import { VideoComponent } from '@shared/components/video/video.component'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { FileViewerV2Component } from './components/file-viewer-v2/file-viewer-v2.component'; import { FileBrowserComponentsModule } from './file-browser-components.module'; @NgModule({ @@ -26,6 +31,7 @@ import { FileBrowserComponentsModule } from './file-browser-components.module'; FileListComponent, FileListItemComponent, FileViewerComponent, + FileViewerV2Component, VideoComponent, ], }) diff --git a/src/app/file-browser/file-browser.routes.ts b/src/app/file-browser/file-browser.routes.ts index 78965211c..e4a117c03 100644 --- a/src/app/file-browser/file-browser.routes.ts +++ b/src/app/file-browser/file-browser.routes.ts @@ -6,6 +6,8 @@ import { RecordResolveService } from '@core/resolves/record-resolve.service'; import { FileListComponent } from '@fileBrowser/components/file-list/file-list.component'; import { FileViewerComponent } from '@fileBrowser/components/file-viewer/file-viewer.component'; +import { RecordResolveV2Service } from '@core/resolves/record-resolve-v2.service'; +import { FileViewerV2Component } from './components/file-viewer-v2/file-viewer-v2.component'; const folderResolve = { currentFolder: FolderResolveService, @@ -15,12 +17,21 @@ const recordResolve = { currentRecord: RecordResolveService, }; +const recordResolveV2 = { + currentRecord: RecordResolveV2Service, +}; + export const fileListChildRoutes = [ { path: 'record/:recArchiveNbr', component: FileViewerComponent, resolve: recordResolve, }, + { + path: 'record/v2/:recArchiveNbr', + component: FileViewerV2Component, + resolve: recordResolveV2, + }, ]; export const routes: Routes = [ diff --git a/src/app/file-browser/utils/utils.ts b/src/app/file-browser/utils/utils.ts index 20f1d2f5c..cd036f2c6 100644 --- a/src/app/file-browser/utils/utils.ts +++ b/src/app/file-browser/utils/utils.ts @@ -4,7 +4,6 @@ export const shareUrlBuilder = (payload: { itemType: 'record' | 'folder'; token: string; itemId: string; - accountId: string; }) => { const urlDict = { local: 'https://local.permanent.org/share', @@ -15,11 +14,9 @@ export const shareUrlBuilder = (payload: { const baseUrl = urlDict[environment.environment]; - const url = new URL(baseUrl); - - Object.keys(payload).map((key) => { - url.searchParams.set(key, payload[key]); - }); + const url = new URL( + `${baseUrl}/view/v2-file-list/${payload.itemType}/${payload.token}/${payload.itemId}`, + ); return url.toString(); }; diff --git a/src/app/models/file-vo.ts b/src/app/models/file-vo.ts index 6f9dd732f..022e5c4e6 100644 --- a/src/app/models/file-vo.ts +++ b/src/app/models/file-vo.ts @@ -13,3 +13,14 @@ export interface PermanentFile { downloadURL: string; type: string; } + +export interface File { + size: number; + type: string; + fileId: string; + format: string; + fileUrl: string; + createdAt: string; + updatedAt: string; + downloadUrl: string; +} diff --git a/src/app/models/folder-vo.ts b/src/app/models/folder-vo.ts index 77bffce44..9e8025ad4 100644 --- a/src/app/models/folder-vo.ts +++ b/src/app/models/folder-vo.ts @@ -181,6 +181,7 @@ export class FolderVO export interface FolderVOData extends BaseVOData { folderId?: any; archiveNbr?: any; + archiveNumber?: any; archiveArchiveNbr?: any; archiveId?: any; displayName?: any; diff --git a/src/app/models/get-access-file.ts b/src/app/models/get-access-file.ts index 84edf503a..626c51f02 100644 --- a/src/app/models/get-access-file.ts +++ b/src/app/models/get-access-file.ts @@ -1,15 +1,33 @@ import { prioritizeIf } from '@root/utils/prioritize-if'; -import { FileFormat, PermanentFile } from './file-vo'; +import { File, FileFormat, PermanentFile } from './file-vo'; export interface HasFiles { FileVOs: PermanentFile[]; } -function getArchivematicaAccess(files: PermanentFile[]) { +export interface HasFilesV2 { + files?: File[]; +} + +// Overload signatures +function getArchivematicaAccess(files: File[]): File | undefined; +function getArchivematicaAccess( + files: PermanentFile[], +): PermanentFile | undefined; + +// Implementation +function getArchivematicaAccess( + files: (File | PermanentFile)[], +): File | PermanentFile | undefined { return files.find((file) => file.format === FileFormat.ArchivematicaAccess); } -function getPrioritizedConvertedFile(files: PermanentFile[]) { +function getPrioritizedConvertedFile(files: PermanentFile[]); +function getPrioritizedConvertedFile(files: File[]); + +function getPrioritizedConvertedFile( + files: (File | PermanentFile)[], +): File | PermanentFile | undefined { return prioritizeIf( files.filter((file) => file.format === FileFormat.Converted), (file) => file.type.includes('pdf'), @@ -24,3 +42,12 @@ export function GetAccessFile(record: HasFiles): PermanentFile | undefined { files[0] ); } + +export function GetAccessFileV2(record: HasFilesV2): File | undefined { + const files = record?.files ?? []; + return ( + getArchivematicaAccess(files) || + getPrioritizedConvertedFile(files) || + files[0] + ); +} diff --git a/src/app/models/locn-vo.ts b/src/app/models/locn-vo.ts index 1a3423fbc..6b6d4b07a 100644 --- a/src/app/models/locn-vo.ts +++ b/src/app/models/locn-vo.ts @@ -29,3 +29,17 @@ export interface LocnVOData extends BaseVOData { status?: string; type?: string; } + +export interface Locn { + id: string; + streetNumber: string; + streetName: string; + locality: string; + county: string; + state: string; + latitude: number; + longitude: number; + country: string; + countryCode: string; + displayName?: string | null; +} diff --git a/src/app/models/record-vo.ts b/src/app/models/record-vo.ts index 77041ebc8..25cde2d1c 100644 --- a/src/app/models/record-vo.ts +++ b/src/app/models/record-vo.ts @@ -8,8 +8,8 @@ import { AccessRoleType } from './access-role'; import { TimezoneVOData } from './timezone-vo'; import { ChildItemData, HasParentFolder } from './folder-vo'; import { RecordType, FolderLinkType } from './vo-types'; -import { LocnVOData } from './locn-vo'; -import { TagVOData } from './tag-vo'; +import { Locn, LocnVOData } from './locn-vo'; +import { Tag, TagVOData } from './tag-vo'; import { ArchiveVO } from './archive-vo'; import { HasThumbnails } from './get-thumbnail'; import { FileFormat, PermanentFile } from './file-vo'; @@ -52,6 +52,7 @@ export class RecordVO public uploadFileName; public downloadName; public uploadAccountId; + public folderId; public size; public description; public displayDT; @@ -207,6 +208,7 @@ export interface RecordVOData extends BaseVOData { thumbURL2000?: string; thumbnail256?: string; thumbnail256CloudPath?: string; + folderId?: any; thumbDT?: any; fileStatus?: any; status?: any; @@ -241,3 +243,60 @@ export interface RecordVOData extends BaseVOData { AccessVO?: any; isFolder?: boolean; } + +export interface Record { + recordId?: string; + displayName?: string; + archiveId?: string; + archiveNumber?: string; + description?: string | null; + publicAt?: string | null; + downloadName?: string; + uploadFileName?: string; + uploadAccountId?: string; + uploadPayerAccountId?: string; + size?: number; + displayDate?: string; + fileCreatedAt?: string | null; + imageRatio?: number; + thumbUrl200?: string; + thumbUrl500?: string; + thumbUrl1000?: string; + thumbUrl2000?: string; + status?: string; + type?: string; + createdAt?: string; + updatedAt?: string; + altText?: string | null; + files?: { + size: number; + type: string; + fileId: string; + format: string; + fileUrl: string; + createdAt: string; + updatedAt: string; + downloadUrl: string; + }[]; + folderLinkId?: string; + folderLinkType?: string; + parentFolderId?: string; + parentFolderLinkId?: string; + parentFolderArchiveNumber?: string; + tags?: Tag[]; + archiveArchiveNumber?: string; + location?: Locn; + dataStatus?: number; + shares?: any | null; + archive?: { + id: string; + archiveNumber: string; + name: string; + }; + shareLink?: { + creatorAccount: { + id: string; + name: string; + }; + }; +} diff --git a/src/app/models/tag-vo.ts b/src/app/models/tag-vo.ts index 2ae3e7a33..00431cfd7 100644 --- a/src/app/models/tag-vo.ts +++ b/src/app/models/tag-vo.ts @@ -29,3 +29,9 @@ export class TagVO extends BaseVO implements TagVOData { return this.type?.includes('type.tag.metadata'); } } + +export interface Tag { + id: string; + name: string; + type: string; +} diff --git a/src/app/share-links/models/share-link.ts b/src/app/share-links/models/share-link.ts index c65a37bac..f147d9327 100644 --- a/src/app/share-links/models/share-link.ts +++ b/src/app/share-links/models/share-link.ts @@ -11,6 +11,10 @@ export interface ShareLink { expirationTimestamp?: string; createdAt: Date; updatedAt: Date; + creatorAccount: { + id: string; + name: string; + }; } export interface ShareLinkPayload { diff --git a/src/app/share-links/services/share-links-api.service.spec.ts b/src/app/share-links/services/share-links-api.service.spec.ts index 8f7126e8b..4b6403d16 100644 --- a/src/app/share-links/services/share-links-api.service.spec.ts +++ b/src/app/share-links/services/share-links-api.service.spec.ts @@ -131,6 +131,10 @@ describe('ShareLinksApiService', () => { expirationTimestamp: null, createdAt: new Date('2025-04-09T13:09:07.755Z'), updatedAt: new Date('2025-04-09T13:09:07.755Z'), + creatorAccount: { + id: '1', + name: 'Name', + }, }; const mockApiResponse = { @@ -230,6 +234,10 @@ describe('ShareLinksApiService', () => { expirationTimestamp: null, createdAt: new Date('2025-04-09T13:09:07.755Z'), updatedAt: new Date('2025-04-09T13:09:07.755Z'), + creatorAccount: { + id: '1', + name: 'Name', + }, }; const mockApiResponse = { diff --git a/src/app/share-preview/components/share-preview/share-preview.component.html b/src/app/share-preview/components/share-preview/share-preview.component.html index eede82dc8..5e4946557 100644 --- a/src/app/share-preview/components/share-preview/share-preview.component.html +++ b/src/app/share-preview/components/share-preview/share-preview.component.html @@ -76,12 +76,25 @@ -