-
-
- @if (file.previousFolder) {
-
-
-
-
- {{ file.name ?? '' }}
-
-
- } @else {
-
-
-
-
- {{ file?.name ?? '' }}
-
-
-
-
- @if (file.extra.downloads) {
- {{
- file.kind === 'file' ? file.extra.downloads + ' ' + ('common.labels.downloads' | translate) : ''
- }}
- }
-
-
-
- {{ file.size | fileSize }}
-
-
-
- {{ file.dateModified | date: 'MMM d, y hh:mm a' }}
-
-
- @if (isSomeFileActionAllowed && !selectedFiles().length) {
-
-
-
-
- }
- @if (isDraftResource()) {
-
- }
-
- }
-
-
+
+
+
+ 0"
+ [showMenu]="canShowMenu()"
+ [allowedMenuActions]="allowedMenuActions()"
+ (openParentFolder)="openParentFolder()"
+ (openEntry)="openEntry($event)"
+ (menuAction)="onFileMenuAction($event, file.data)"
+ >
+
- @if (!files().length) {
-
- @if (hasViewOnly() || !supportUpload()) {
+
+
+ @if (!canUpload()) {
{{ 'files.emptyState' | translate }}
} @else {
-
+
{{ 'files.dropText' | translate }}
}
- }
-
+
+
}
-
+
diff --git a/src/app/shared/components/files-tree/files-tree.component.scss b/src/app/shared/components/files-tree/files-tree.component.scss
index 3984d465f..190e70847 100644
--- a/src/app/shared/components/files-tree/files-tree.component.scss
+++ b/src/app/shared/components/files-tree/files-tree.component.scss
@@ -1,88 +1,13 @@
-@use "styles/mixins" as mix;
-
:host {
- min-height: 180px;
display: flex;
flex-direction: column;
+ min-height: 11.25rem;
}
.files-table {
- display: flex;
- flex-direction: column;
border: 1px solid var(--grey-2);
- border-radius: 8px;
- overflow-x: auto;
+ border-radius: 0.5rem;
min-width: 100%;
- min-height: 180px;
-
- &-row {
- color: var(--dark-blue-1);
- display: grid;
- align-items: center;
- grid-template-columns:
- minmax(mix.rem(200px), 32rem) minmax(mix.rem(150px), 0.7fr) minmax(mix.rem(100px), 100px)
- minmax(mix.rem(150px), 1fr) minmax(mix.rem(50px), 50px);
- grid-template-rows: mix.rem(44px);
- border-bottom: 1px solid var(--grey-2);
- padding: 0 mix.rem(12px);
- cursor: pointer;
-
- &:hover {
- background: var(--bg-blue-3);
- }
-
- &:active {
- background: var(--bg-blue-2);
- }
-
- .table-cell {
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- }
-
- > .table-cell:first-child {
- max-width: 95%;
- }
- }
-}
-
-.entry-title {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
- max-width: 100%;
-}
-
-.tree-table {
- .p-tree {
- padding: 0;
- }
-}
-
-.drop-zone {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--white);
- transition:
- background 0.3s ease,
- backdrop-filter 0.3s ease;
- pointer-events: none;
- background: transparent;
-
- &.active {
- backdrop-filter: blur(0.3rem);
- background: rgba(132, 174, 210, 0.5);
- pointer-events: all;
- }
+ min-height: 11.25rem;
+ overflow-x: auto;
}
diff --git a/src/app/shared/components/files-tree/files-tree.component.spec.ts b/src/app/shared/components/files-tree/files-tree.component.spec.ts
index 59a8bf61c..64d84dcbc 100644
--- a/src/app/shared/components/files-tree/files-tree.component.spec.ts
+++ b/src/app/shared/components/files-tree/files-tree.component.spec.ts
@@ -2,7 +2,6 @@ import { MockComponents, MockProvider } from 'ng-mocks';
import { TreeDragDropService } from 'primeng/api';
-import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
@@ -12,14 +11,12 @@ import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'
import { DataciteService } from '@osf/shared/services/datacite/datacite.service';
import { FilesService } from '@osf/shared/services/files.service';
import { ToastService } from '@osf/shared/services/toast.service';
-import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource';
import { FileFolderModel } from '@shared/models/files/file-folder.model';
import { FileLabelModel } from '@shared/models/files/file-label.model';
import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock';
import { provideOSFCore } from '@testing/osf.testing.provider';
import { DataciteServiceMock, DataciteServiceMockType } from '@testing/providers/datacite.service.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
import { FileMenuComponent } from '../file-menu/file-menu.component';
import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component';
@@ -50,15 +47,12 @@ describe('FilesTreeComponent', () => {
providers: [
provideOSFCore(),
provideRouter([]),
- provideMockStore({
- signals: [{ selector: CurrentResourceSelectors.getCurrentResource, value: signal(null) }],
- }),
MockProvider(DataciteService, dataciteMock),
MockProvider(FilesService),
MockProvider(ToastService),
MockProvider(CustomConfirmationService),
MockProvider(CustomDialogService),
- TreeDragDropService,
+ MockProvider(TreeDragDropService),
],
});
diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts
index efe638512..a5e51c8f1 100644
--- a/src/app/shared/components/files-tree/files-tree.component.ts
+++ b/src/app/shared/components/files-tree/files-tree.component.ts
@@ -1,96 +1,70 @@
-import { select } from '@ngxs/store';
-
import { TranslatePipe } from '@ngx-translate/core';
-import { PrimeTemplate, TreeNode } from 'primeng/api';
-import { Button } from 'primeng/button';
-import { Tooltip } from 'primeng/tooltip';
+import { PrimeTemplate, TreeDragDropService } from 'primeng/api';
import { Tree, TreeLazyLoadEvent, TreeNodeDropEvent, TreeNodeSelectEvent } from 'primeng/tree';
-import { Clipboard } from '@angular/cdk/clipboard';
-import { DatePipe, isPlatformBrowser } from '@angular/common';
+import { filter } from 'rxjs';
+
+import { isPlatformBrowser } from '@angular/common';
import {
- AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
- ElementRef,
- HostBinding,
inject,
input,
- OnDestroy,
+ model,
output,
PLATFORM_ID,
signal,
- viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { ActivatedRoute, Router } from '@angular/router';
+import { Router } from '@angular/router';
-import { ENVIRONMENT } from '@core/provider/environment.provider';
import { ConfirmMoveFileDialogComponent } from '@osf/features/files/components/confirm-move-file-dialog/confirm-move-file-dialog.component';
import { MoveFileDialogComponent } from '@osf/features/files/components/move-file-dialog/move-file-dialog.component';
import { RenameFileDialogComponent } from '@osf/features/files/components/rename-file-dialog/rename-file-dialog.component';
-import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants';
-import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive';
import { FileKind } from '@osf/shared/enums/file-kind.enum';
import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum';
+import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum';
+import { FileTreeMapper } from '@osf/shared/mappers/files/file-tree.mapper';
import { FilesMapper } from '@osf/shared/mappers/files/files.mapper';
-import { FileSizePipe } from '@osf/shared/pipes/file-size.pipe';
+import { FilePageLinkModel } from '@osf/shared/models/files/file-page-link.model';
+import { RenamedFileLinkModel } from '@osf/shared/models/files/renamed-file-link.model';
import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { DataciteService } from '@osf/shared/services/datacite/datacite.service';
import { FilesService } from '@osf/shared/services/files.service';
-import { ToastService } from '@osf/shared/services/toast.service';
+import { FilesShareEmbedService } from '@osf/shared/services/files-share-embed.service';
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
import { FileModel } from '@shared/models/files/file.model';
import { FileFolderModel } from '@shared/models/files/file-folder.model';
import { FileLabelModel } from '@shared/models/files/file-label.model';
import { FileMenuAction, FileMenuFlags } from '@shared/models/files/file-menu-action.model';
-import { CurrentResourceSelectors } from '@shared/stores/current-resource';
-import { FileMenuComponent } from '../file-menu/file-menu.component';
+import { FilesDropZoneComponent } from '../files-drop-zone/files-drop-zone.component';
+import { FilesTreeRowComponent } from '../files-tree-row/files-tree-row.component';
import { LoadingSpinnerComponent } from '../loading-spinner/loading-spinner.component';
-// [NS] Temporary fix
-type FileTreeNode = FileModel & TreeNode;
-
@Component({
selector: 'osf-files-tree',
- imports: [
- DatePipe,
- FileSizePipe,
- PrimeTemplate,
- TranslatePipe,
- Tree,
- LoadingSpinnerComponent,
- FileMenuComponent,
- StopPropagationDirective,
- Button,
- Tooltip,
- ],
+ imports: [PrimeTemplate, TranslatePipe, Tree, LoadingSpinnerComponent, FilesDropZoneComponent, FilesTreeRowComponent],
+ providers: [TreeDragDropService],
templateUrl: './files-tree.component.html',
styleUrl: './files-tree.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class FilesTreeComponent implements OnDestroy, AfterViewInit {
- @HostBinding('class') classes = 'relative';
- private dropZoneContainerRef = viewChild
('dropZoneContainer');
- readonly filesService = inject(FilesService);
- readonly router = inject(Router);
- readonly toastService = inject(ToastService);
- readonly route = inject(ActivatedRoute);
- readonly customConfirmationService = inject(CustomConfirmationService);
- readonly customDialogService = inject(CustomDialogService);
- readonly dataciteService = inject(DataciteService);
- private readonly viewOnlyService = inject(ViewOnlyLinkHelperService);
-
+export class FilesTreeComponent {
private readonly destroyRef = inject(DestroyRef);
- private readonly environment = inject(ENVIRONMENT);
- private readonly platformId = inject(PLATFORM_ID);
- readonly clipboard = inject(Clipboard);
+ private readonly router = inject(Router);
+ private readonly filesService = inject(FilesService);
+ private readonly customConfirmationService = inject(CustomConfirmationService);
+ private readonly customDialogService = inject(CustomDialogService);
+ private readonly dataciteService = inject(DataciteService);
+ private readonly filesShareEmbedService = inject(FilesShareEmbedService);
+ private readonly viewOnlyService = inject(ViewOnlyLinkHelperService);
+ private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
files = input.required();
totalCount = input(0);
@@ -98,6 +72,7 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
currentFolder = input.required();
storage = input.required();
resourceId = input.required();
+ resourceType = input(CurrentResourceType.Projects);
viewOnly = input(true);
provider = input();
allowedMenuActions = input({} as FileMenuFlags);
@@ -112,60 +87,44 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
setCurrentFolder = output();
setMoveDialogCurrentFolder = output();
deleteEntryAction = output();
- renameEntryAction = output<{ newName: string; link: string }>();
- loadFiles = output<{ link: string; page: number }>();
+ renameEntryAction = output();
+ loadFiles = output();
selectFile = output();
unselectFile = output();
clearSelection = output();
- updateFoldersStack = output();
resetFilesProvider = output();
- readonly resourceMetadata = select(CurrentResourceSelectors.getCurrentResource);
-
- foldersStack: FileFolderModel[] = [];
+ foldersStack = model([]);
lastSelectedFile: FileModel | null = null;
itemsPerPage = 10;
virtualScrollItemSize = 46;
- isDragOver = signal(false);
isLoadingMore = signal(false);
- hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router) || this.viewOnly());
- visibleFilesCount = computed((): number => {
- const height = parseInt(this.scrollHeight(), 10);
- return Math.ceil(height / this.virtualScrollItemSize);
- });
-
- get isSomeFileActionAllowed(): boolean {
- return Object.keys(this.allowedMenuActions()).length > 0;
- }
+ readonly hasViewOnly = computed(() => this.viewOnlyService.hasViewOnlyParam(this.router) || this.viewOnly());
+ readonly canUpload = computed(() => !this.hasViewOnly() && this.supportUpload());
+ readonly canShowMenu = computed(
+ () => Object.keys(this.allowedMenuActions()).length > 0 && !this.selectedFiles().length
+ );
readonly nodes = computed(() => {
const currentFolder = this.currentFolder();
const files = this.files();
- const hasParent = this.foldersStack.length > 0;
- if (hasParent) {
- return [
- {
- ...currentFolder,
- previousFolder: hasParent,
- },
- ...files,
- ] as FileModel[];
- } else {
- return [...files];
- }
+
+ const values = this.foldersStack().length
+ ? ([{ ...currentFolder, previousFolder: true }, ...files] as FileModel[])
+ : files;
+
+ return FileTreeMapper.toTreeNodes(values);
});
- // [NS] Temporary fix
- readonly selectedNodes = computed(() => this.selectedFiles() as FileTreeNode[]);
+ readonly selectedNodes = computed(() => FileTreeMapper.toTreeNodes(this.selectedFiles()));
constructor() {
effect(() => {
const storageChanged = this.storage();
if (storageChanged) {
- this.foldersStack = [];
- this.updateFoldersStack.emit(this.foldersStack);
+ this.foldersStack.set([]);
}
});
@@ -176,83 +135,29 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
});
}
- ngAfterViewInit(): void {
- if (!this.viewOnly()) {
- this.dropZoneContainerRef()?.nativeElement?.addEventListener('dragenter', this.dragEnterHandler);
- }
- }
-
- ngOnDestroy(): void {
- if (this.dropZoneContainerRef()?.nativeElement) {
- this.dropZoneContainerRef()!.nativeElement.removeEventListener('dragenter', this.dragEnterHandler);
- }
- }
-
- private dragEnterHandler = (event: DragEvent) => {
- if (event.dataTransfer?.types?.includes('Files') && !this.viewOnly()) {
- this.isDragOver.set(true);
- }
- };
-
- onDragOver(event: DragEvent) {
- if (this.viewOnly()) {
+ onDropFiles(fileArray: File[]): void {
+ if (!fileArray.length) {
return;
}
- event.preventDefault();
- event.stopPropagation();
- event.dataTransfer!.dropEffect = 'copy';
- this.isDragOver.set(true);
- }
- onDragLeave(event: Event) {
- if (this.viewOnly()) {
- return;
- }
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver.set(false);
- }
-
- onDrop(event: DragEvent) {
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver.set(false);
-
- if (this.viewOnly()) {
- return;
- }
+ const isMultiple = fileArray.length > 1;
- const files = event.dataTransfer?.files;
-
- if (files && files.length > 0) {
- const fileArray = Array.from(files);
- const isMultiple = files.length > 1;
-
- this.customConfirmationService.confirmAccept({
- headerKey: isMultiple ? 'files.dialogs.uploadFiles.title' : 'files.dialogs.uploadFile.title',
- messageParams: isMultiple ? { count: files.length } : { name: files[0].name },
- messageKey: isMultiple ? 'files.dialogs.uploadFiles.message' : 'files.dialogs.uploadFile.message',
- acceptLabelKey: 'common.buttons.upload',
- onConfirm: () => this.uploadFilesConfirmed.emit(fileArray),
- });
- }
+ this.customConfirmationService.confirmAccept({
+ headerKey: isMultiple ? 'files.dialogs.uploadFiles.title' : 'files.dialogs.uploadFile.title',
+ messageParams: isMultiple ? { count: fileArray.length } : { name: fileArray[0].name },
+ messageKey: isMultiple ? 'files.dialogs.uploadFiles.message' : 'files.dialogs.uploadFile.message',
+ acceptLabelKey: 'common.buttons.upload',
+ onConfirm: () => this.uploadFilesConfirmed.emit(fileArray),
+ });
}
- openEntry(event: Event, file: FileModel | FileFolderModel) {
- event.stopPropagation();
+ openEntry(file: FileModel | FileFolderModel) {
if (file.kind === FileKind.File) {
- if (file.guid) {
- this.entryFileClicked.emit(file);
- } else {
- this.filesService.getFileGuid(file.id).subscribe((file) => {
- this.entryFileClicked.emit(file);
- });
- }
+ this.entryFileClicked.emit(file);
} else {
const current = this.currentFolder();
if (current) {
- this.foldersStack.push(current);
- this.updateFoldersStack.emit(this.foldersStack);
+ this.foldersStack.update((stack) => [...stack, current]);
}
const folder = FilesMapper.mapFileToFolder(file as FileModel);
this.setCurrentFolder.emit(folder);
@@ -261,11 +166,14 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
}
openParentFolder() {
- const previous = this.foldersStack.pop();
- this.updateFoldersStack.emit(this.foldersStack);
+ const stack = this.foldersStack();
+ const previous = stack[stack.length - 1];
+ this.foldersStack.set(stack.slice(0, -1));
+
if (previous) {
this.setCurrentFolder.emit(previous);
}
+
this.clearSelection.emit();
}
@@ -298,47 +206,15 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
}
downloadFileOrFolder(file: FileModel) {
- const resourceType = this.resourceMetadata()?.type ?? 'nodes';
this.dataciteService
- .logFileDownload(this.resourceId(), resourceType)
+ .logFileDownload(this.resourceId(), this.resourceType())
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
+
if (file.kind === FileKind.File) {
this.downloadFile(file.links.download);
} else {
- const folder = FilesMapper.mapFileToFolder(file as FileModel);
- this.downloadFolder(folder.links.download);
- }
- }
-
- private handleShareAction(file: FileModel, shareType?: string): void {
- const emailLink = `mailto:?subject=${file.name}&body=${file.links.html}`;
- const twitterLink = `https://twitter.com/intent/tweet?url=${file.links.html}&text=${file.name}&via=OSFramework`;
- const facebookLink = `https://www.facebook.com/dialog/share?app_id=${this.environment.facebookAppId}&display=popup&href=${file.links.html}&redirect_uri=${file.links.html}`;
-
- switch (shareType) {
- case 'email':
- this.openLink(emailLink);
- break;
- case 'twitter':
- this.openLinkNewTab(twitterLink);
- break;
- case 'facebook':
- this.openLinkNewTab(facebookLink);
- break;
- }
- }
-
- private handleEmbedAction(file: FileModel, embedType?: string): void {
- let embedHtml = '';
- if (embedType === 'dynamic') {
- embedHtml = embedDynamicJs.replace('ENCODED_URL', file.links.render);
- } else if (embedType === 'static') {
- embedHtml = embedStaticHtml.replace('ENCODED_URL', file.links.render);
- }
-
- if (embedHtml) {
- this.copyToClipboard(embedHtml);
+ this.downloadFolder(file.links.upload);
}
}
@@ -349,57 +225,37 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
messageKey:
file.kind === FileKind.Folder ? 'files.dialogs.deleteFolder.message' : 'files.dialogs.deleteFile.message',
acceptLabelKey: 'common.buttons.remove',
- onConfirm: () => this.confirmDeleteEntry(file),
+ onConfirm: () => this.deleteEntryAction.emit(file),
});
}
- confirmDeleteEntry(file: FileModel): void {
- this.deleteEntryAction.emit(file);
- }
-
confirmRename(file: FileModel): void {
this.customDialogService
.open(RenameFileDialogComponent, {
header: 'files.dialogs.renameFile.title',
width: '448px',
- data: {
- currentName: file.name,
- },
+ data: { currentName: file.name },
})
- .onClose.subscribe((newName: string) => {
- if (newName) {
- this.renameEntry(newName, file);
+ .onClose.pipe(
+ takeUntilDestroyed(this.destroyRef),
+ filter((newName: string) => !!newName)
+ )
+ .subscribe((newName) => {
+ if (newName.trim() && file.links.upload) {
+ const link = file.links.upload;
+ this.renameEntryAction.emit({ newName, link });
}
});
}
- renameEntry(newName: string, file: FileModel): void {
- if (newName.trim() && file.links.upload) {
- const link = file.links.upload;
- this.renameEntryAction.emit({ newName, link });
- }
- }
-
downloadFile(link: string): void {
- if (isPlatformBrowser(this.platformId)) {
+ if (this.isBrowser) {
window.open(link)?.focus();
}
}
- openLink(link: string): void {
- if (isPlatformBrowser(this.platformId)) {
- window.location.href = link;
- }
- }
-
- openLinkNewTab(link: string): void {
- if (isPlatformBrowser(this.platformId)) {
- window.open(link, '_blank', 'noopener,noreferrer');
- }
- }
-
downloadFolder(downloadLink: string): void {
- if (isPlatformBrowser(this.platformId) && downloadLink) {
+ if (downloadLink) {
const link = this.filesService.getFolderDownloadLink(downloadLink);
window.open(link, '_blank')?.focus();
}
@@ -416,32 +272,11 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
resourceId: this.resourceId(),
action: action,
storageProvider: this.storage()?.folder.provider,
- foldersStack: structuredClone(this.foldersStack),
+ foldersStack: structuredClone(this.foldersStack()),
initialFolder: structuredClone(this.currentFolder()),
},
})
- .onClose.subscribe(() => {
- this.resetFilesProvider.emit();
- });
- }
-
- copyToClipboard(embedHtml: string): void {
- this.clipboard.copy(embedHtml);
- this.toastService.showSuccess('files.detail.toast.copiedToClipboard');
- }
-
- private loadNextPage(): void {
- const total = this.totalCount();
- const loaded = this.files().length;
- const nextPage = Math.floor(loaded / this.itemsPerPage) + 1;
-
- if (!this.isLoadingMore() && loaded < total) {
- this.isLoadingMore.set(true);
- this.loadFiles.emit({
- link: this.currentFolder()?.links.filesLink ?? '',
- page: nextPage,
- });
- }
+ .onClose.subscribe(() => this.resetFilesProvider.emit());
}
onLazyLoad(event: TreeLazyLoadEvent) {
@@ -453,7 +288,12 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
onNodeSelect(event: TreeNodeSelectEvent) {
const files = this.files();
- const selectedNode = event.node as FileModel;
+ const selectedNode = event.node.data as FileModel;
+
+ if (!selectedNode) {
+ return;
+ }
+
if ((event.originalEvent as PointerEvent).shiftKey && this.lastSelectedFile) {
const lastIndex = files.indexOf(this.lastSelectedFile);
const currentIndex = files.indexOf(selectedNode);
@@ -468,25 +308,42 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
this.selectFile.emit(file);
}
}
+
this.selectFile.emit(selectedNode);
this.lastSelectedFile = selectedNode;
}
onNodeDrop(event: TreeNodeDropEvent) {
- const dropFile = event.dropNode as FileModel;
- if (dropFile.kind !== FileKind.Folder) {
+ const dropFile = event.dropNode?.data as FileModel;
+
+ if (dropFile?.kind !== FileKind.Folder) {
+ return;
+ }
+
+ const selectedFiles = this.selectedFiles();
+ const dragFile = event.dragNode?.data as FileModel;
+
+ if (!dragFile) {
return;
}
- const files = this.selectedFiles();
- const dragFile = event.dragNode as FileModel;
- if (!files.includes(dragFile)) {
+
+ const filesToMove = selectedFiles.includes(dragFile) ? selectedFiles : [...selectedFiles, dragFile];
+
+ if (!selectedFiles.includes(dragFile)) {
this.selectFile.emit(dragFile);
}
- this.moveFilesTo(files, dropFile);
+
+ this.moveFilesTo(filesToMove, dropFile);
}
onNodeUnselect(event: TreeNodeSelectEvent) {
- this.unselectFile.emit(event.node as FileModel);
+ const unselectedNode = event.node.data as FileModel;
+
+ if (!unselectedNode) {
+ return;
+ }
+
+ this.unselectFile.emit(unselectedNode);
}
private moveFilesTo(files: FileModel[], destination: FileModel) {
@@ -502,8 +359,35 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit {
storageProvider: this.storage()?.folder.provider,
},
})
- .onClose.subscribe(() => {
- this.resetFilesProvider.emit();
- });
+ .onClose.subscribe(() => this.resetFilesProvider.emit());
+ }
+
+ private loadNextPage(): void {
+ const total = this.totalCount();
+ const loaded = this.files().length;
+ const nextPage = Math.floor(loaded / this.itemsPerPage) + 1;
+
+ if (!this.isLoadingMore() && loaded < total) {
+ this.isLoadingMore.set(true);
+ this.loadFiles.emit({ link: this.currentFolder()?.links.filesLink ?? '', page: nextPage });
+ }
+ }
+
+ private handleShareAction(file: FileModel, shareType?: string): void {
+ const shareAction = this.filesShareEmbedService.getShareLink(file, shareType);
+ if (!shareAction || !this.isBrowser) {
+ return;
+ }
+
+ if (shareAction.target === '_self') {
+ window.location.href = shareAction.link;
+ return;
+ }
+
+ window.open(shareAction.link, shareAction.target, 'noopener,noreferrer');
+ }
+
+ private handleEmbedAction(file: FileModel, embedType?: string): void {
+ this.filesShareEmbedService.copyEmbedToClipboard(file.links.render, embedType);
}
}
diff --git a/src/app/shared/components/socials-share-button/socials-share-button.component.ts b/src/app/shared/components/socials-share-button/socials-share-button.component.ts
index 23ed332eb..f51128d45 100644
--- a/src/app/shared/components/socials-share-button/socials-share-button.component.ts
+++ b/src/app/shared/components/socials-share-button/socials-share-button.component.ts
@@ -33,11 +33,7 @@ export class SocialsShareButtonComponent {
? this.socialShareService.createPreprintUrl(this.resourceId(), this.resourceProvider())
: this.socialShareService.createGuidUrl(this.resourceId());
- const shareableContent: SocialShareContentModel = {
- id: this.resourceId(),
- title: this.resourceTitle(),
- url: resourceUrl,
- };
+ const shareableContent: SocialShareContentModel = { title: this.resourceTitle(), url: resourceUrl };
return this.socialShareService.generateSocialActionItems(shareableContent);
});
diff --git a/src/app/shared/components/status-badge/status-badge.component.html b/src/app/shared/components/status-badge/status-badge.component.html
index 0f2b18722..261413749 100644
--- a/src/app/shared/components/status-badge/status-badge.component.html
+++ b/src/app/shared/components/status-badge/status-badge.component.html
@@ -1,3 +1,3 @@
-@if (label) {
-
+@if (label()) {
+
}
diff --git a/src/app/shared/components/status-badge/status-badge.component.ts b/src/app/shared/components/status-badge/status-badge.component.ts
index 5610e175b..1fa211862 100644
--- a/src/app/shared/components/status-badge/status-badge.component.ts
+++ b/src/app/shared/components/status-badge/status-badge.component.ts
@@ -2,11 +2,10 @@ import { TranslatePipe } from '@ngx-translate/core';
import { Tag } from 'primeng/tag';
-import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { RegistryStatusMap } from '@osf/shared/constants/registration-statuses';
import { RegistryStatus } from '@osf/shared/enums/registry-status.enum';
-import { TagSeverityType } from '@osf/shared/models/severity.type';
@Component({
selector: 'osf-status-badge',
@@ -18,11 +17,6 @@ import { TagSeverityType } from '@osf/shared/models/severity.type';
export class StatusBadgeComponent {
status = input.required();
- get label(): string {
- return RegistryStatusMap[this.status()]?.label ?? 'Unknown';
- }
-
- get severity(): TagSeverityType | null {
- return RegistryStatusMap[this.status()]?.severity ?? null;
- }
+ label = computed(() => RegistryStatusMap[this.status()]?.label ?? 'resourceCard.type.null');
+ severity = computed(() => RegistryStatusMap[this.status()]?.severity ?? null);
}
diff --git a/src/app/shared/components/subjects/subjects.component.ts b/src/app/shared/components/subjects/subjects.component.ts
index 966742a8b..f201d0fbe 100644
--- a/src/app/shared/components/subjects/subjects.component.ts
+++ b/src/app/shared/components/subjects/subjects.component.ts
@@ -11,7 +11,8 @@ import { Tree, TreeModule } from 'primeng/tree';
import { debounceTime, distinctUntilChanged } from 'rxjs';
-import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, input, output } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule } from '@angular/forms';
import { SubjectModel } from '@osf/shared/models/subject/subject.model';
@@ -27,11 +28,14 @@ import { SearchInputComponent } from '../search-input/search-input.component';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubjectsComponent {
+ readonly destroyRef = inject(DestroyRef);
+
subjects = select(SubjectsSelectors.getSubjects);
subjectsLoading = select(SubjectsSelectors.getSubjectsLoading);
searchedSubjects = select(SubjectsSelectors.getSearchedSubjects);
- areSubjectsUpdating = input(false);
isSearching = select(SubjectsSelectors.getSearchedSubjectsLoading);
+
+ areSubjectsUpdating = input(false);
selected = input([]);
readonly = input(false);
searchChanged = output();
@@ -51,9 +55,11 @@ export class SubjectsComponent {
searchControl = new FormControl('');
constructor() {
- this.searchControl.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((value) => {
- this.searchChanged.emit(value ?? '');
- });
+ this.searchControl.valueChanges
+ .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
+ .subscribe((value) => {
+ this.searchChanged.emit(value ?? '');
+ });
}
loadNode(event: TreeNode) {
diff --git a/src/app/features/files/constants/embed-content.constants.ts b/src/app/shared/constants/file-embed.constants.ts
similarity index 100%
rename from src/app/features/files/constants/embed-content.constants.ts
rename to src/app/shared/constants/file-embed.constants.ts
diff --git a/src/app/shared/constants/social-share.config.ts b/src/app/shared/constants/social-share.config.ts
index 88f7d17e2..7a0e2bfaf 100644
--- a/src/app/shared/constants/social-share.config.ts
+++ b/src/app/shared/constants/social-share.config.ts
@@ -1,7 +1,8 @@
export const SOCIAL_SHARE_URLS = {
email: 'mailto:',
- twitter: { preview_url: 'https://twitter.com/intent/tweet', viaHandle: 'OsfFramework' },
+ x: { preview_url: 'https://x.com/intent/tweet', viaHandle: 'OsfFramework' },
facebook: 'https://www.facebook.com/sharer/sharer.php',
+ facebookShare: 'https://www.facebook.com/dialog/share',
linkedIn: 'https://www.linkedin.com/sharing/share-offsite',
mastodon: 'https://mastodonshare.com',
bluesky: 'https://bsky.app/intent/compose',
diff --git a/src/app/shared/helpers/mfr-url.helper.ts b/src/app/shared/helpers/mfr-url.helper.ts
new file mode 100644
index 000000000..a3fa1093b
--- /dev/null
+++ b/src/app/shared/helpers/mfr-url.helper.ts
@@ -0,0 +1,19 @@
+export function getMfrUrlWithVersion(
+ mfrUrl: string | undefined,
+ version?: string,
+ viewOnlyParam?: string | null
+): string | null {
+ if (!mfrUrl) return null;
+ const mfrUrlObj = new URL(mfrUrl);
+ const encodedDownloadUrl = mfrUrlObj.searchParams.get('url');
+ if (!encodedDownloadUrl) return mfrUrl;
+
+ const downloadUrlObj = new URL(decodeURIComponent(encodedDownloadUrl));
+
+ if (version) downloadUrlObj.searchParams.set('version', version);
+ if (viewOnlyParam) downloadUrlObj.searchParams.set('view_only', viewOnlyParam);
+
+ mfrUrlObj.searchParams.set('url', downloadUrlObj.toString());
+
+ return mfrUrlObj.toString();
+}
diff --git a/src/app/shared/helpers/storage-addon-options.helper.ts b/src/app/shared/helpers/storage-addon-options.helper.ts
new file mode 100644
index 000000000..18ec482b5
--- /dev/null
+++ b/src/app/shared/helpers/storage-addon-options.helper.ts
@@ -0,0 +1,31 @@
+import { FileProvider } from '@osf/features/files/constants';
+import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model';
+import { FileFolderModel } from '@shared/models/files/file-folder.model';
+import { FileLabelModel } from '@shared/models/files/file-label.model';
+
+export function getConfiguredStorageAddonDisplayName(
+ addons: ConfiguredAddonModel[],
+ provider: string,
+ osfStorageLabel: string
+): string {
+ if (provider === FileProvider.OsfStorage) {
+ return osfStorageLabel;
+ }
+
+ return addons.find((addon) => addon.externalServiceName === provider)?.displayName ?? '';
+}
+
+export function mapRootFoldersToStorageLabels(
+ rootFolders: FileFolderModel[] | null | undefined,
+ addons: ConfiguredAddonModel[] | null | undefined,
+ osfStorageLabel: string
+): FileLabelModel[] {
+ if (!rootFolders || !addons) {
+ return [];
+ }
+
+ return rootFolders.map((folder) => ({
+ label: getConfiguredStorageAddonDisplayName(addons, folder.provider, osfStorageLabel),
+ folder,
+ }));
+}
diff --git a/src/app/shared/mappers/files/file-tree.mapper.ts b/src/app/shared/mappers/files/file-tree.mapper.ts
new file mode 100644
index 000000000..7f84211fe
--- /dev/null
+++ b/src/app/shared/mappers/files/file-tree.mapper.ts
@@ -0,0 +1,16 @@
+import { FileModel } from '@shared/models/files/file.model';
+import { FileTreeNode } from '@shared/models/files/file-tree-node.model';
+
+export class FileTreeMapper {
+ static toTreeNode(file: FileModel): FileTreeNode {
+ return {
+ key: file.id,
+ label: file.name,
+ data: file,
+ };
+ }
+
+ static toTreeNodes(files: FileModel[]): FileTreeNode[] {
+ return files.map((file) => this.toTreeNode(file));
+ }
+}
diff --git a/src/app/shared/mappers/files/files.mapper.ts b/src/app/shared/mappers/files/files.mapper.ts
index f792acf78..6723a3042 100644
--- a/src/app/shared/mappers/files/files.mapper.ts
+++ b/src/app/shared/mappers/files/files.mapper.ts
@@ -60,6 +60,8 @@ export class FilesMapper {
}
static getFileDetails(data: FileDetailsDataJsonApi): FileDetailsModel {
+ const target = data.embeds?.target?.data;
+
return {
id: data.id,
guid: data.attributes.guid,
@@ -76,7 +78,7 @@ export class FilesMapper {
showAsUnviewed: data.attributes.show_as_unviewed,
extra: this.getFileExtra(data.attributes.extra),
links: this.getFileLinks(data.links),
- target: BaseNodeMapper.getNodeData(data.embeds!.target.data),
+ target: target ? BaseNodeMapper.getNodeData(target) : null,
};
}
diff --git a/src/app/shared/models/files/file-link.model.ts b/src/app/shared/models/files/file-link.model.ts
new file mode 100644
index 000000000..abf1f8d49
--- /dev/null
+++ b/src/app/shared/models/files/file-link.model.ts
@@ -0,0 +1,4 @@
+export interface FileLinkModel {
+ file: File;
+ link: string;
+}
diff --git a/src/app/shared/models/files/file-page-link.model.ts b/src/app/shared/models/files/file-page-link.model.ts
new file mode 100644
index 000000000..a5b6ed0f5
--- /dev/null
+++ b/src/app/shared/models/files/file-page-link.model.ts
@@ -0,0 +1,4 @@
+export interface FilePageLinkModel {
+ link: string;
+ page: number;
+}
diff --git a/src/app/shared/models/files/file-share-link.model.ts b/src/app/shared/models/files/file-share-link.model.ts
new file mode 100644
index 000000000..60a018ea6
--- /dev/null
+++ b/src/app/shared/models/files/file-share-link.model.ts
@@ -0,0 +1,4 @@
+export interface FileShareLink {
+ link: string;
+ target: '_self' | '_blank';
+}
diff --git a/src/app/shared/models/files/file-tree-node.model.ts b/src/app/shared/models/files/file-tree-node.model.ts
new file mode 100644
index 000000000..7f35896a8
--- /dev/null
+++ b/src/app/shared/models/files/file-tree-node.model.ts
@@ -0,0 +1,5 @@
+import { TreeNode } from 'primeng/api';
+
+import { FileModel } from './file.model';
+
+export type FileTreeNode = TreeNode;
diff --git a/src/app/shared/models/files/file.model.ts b/src/app/shared/models/files/file.model.ts
index c60efe6e8..f49bc8215 100644
--- a/src/app/shared/models/files/file.model.ts
+++ b/src/app/shared/models/files/file.model.ts
@@ -29,7 +29,7 @@ export interface FileDetailsModel extends BaseFileModel {
currentVersion: number;
showAsUnviewed: boolean;
links: FileLinksModel;
- target: BaseNodeModel;
+ target: BaseNodeModel | null;
}
export interface FileExtraModel {
diff --git a/src/app/shared/models/files/renamed-file-link.model.ts b/src/app/shared/models/files/renamed-file-link.model.ts
new file mode 100644
index 000000000..81876ff0e
--- /dev/null
+++ b/src/app/shared/models/files/renamed-file-link.model.ts
@@ -0,0 +1,4 @@
+export interface RenamedFileLinkModel {
+ newName: string;
+ link: string;
+}
diff --git a/src/app/shared/models/socials/social-share-content.model.ts b/src/app/shared/models/socials/social-share-content.model.ts
index fc5b94031..ec393ac08 100644
--- a/src/app/shared/models/socials/social-share-content.model.ts
+++ b/src/app/shared/models/socials/social-share-content.model.ts
@@ -1,5 +1,4 @@
export interface SocialShareContentModel {
- id: string;
title: string;
url: string;
}
diff --git a/src/app/shared/services/files-share-embed.service.ts b/src/app/shared/services/files-share-embed.service.ts
new file mode 100644
index 000000000..399f81658
--- /dev/null
+++ b/src/app/shared/services/files-share-embed.service.ts
@@ -0,0 +1,77 @@
+import { Clipboard } from '@angular/cdk/clipboard';
+import { inject, Injectable } from '@angular/core';
+
+import { embedDynamicJs, embedStaticHtml } from '@shared/constants/file-embed.constants';
+
+import { FileModel } from '../models/files/file.model';
+import { FileShareLink } from '../models/files/file-share-link.model';
+
+import { SocialShareService } from './social-share.service';
+import { ToastService } from './toast.service';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class FilesShareEmbedService {
+ private readonly clipboard = inject(Clipboard);
+ private readonly socialShareService = inject(SocialShareService);
+ private readonly toastService = inject(ToastService);
+
+ private readonly EMBED_PLACEHOLDER = 'ENCODED_URL';
+
+ getShareLink(file: FileModel, shareType?: string): FileShareLink | null {
+ const name = file.name ?? '';
+ const url = file.links?.html ?? '';
+
+ if (!url) {
+ return null;
+ }
+
+ switch (shareType) {
+ case 'email':
+ return {
+ link: this.socialShareService.getEmailLink(name, url),
+ target: '_self',
+ };
+ case 'twitter':
+ return {
+ link: this.socialShareService.getXLink(name, url),
+ target: '_blank',
+ };
+ case 'facebook':
+ return {
+ link: this.socialShareService.getFacebookLink(url),
+ target: '_blank',
+ };
+ default:
+ return null;
+ }
+ }
+
+ getEmbedHtml(url: string, embedType?: string): string {
+ switch (embedType) {
+ case 'dynamic':
+ return embedDynamicJs.replace(this.EMBED_PLACEHOLDER, url);
+ case 'static':
+ return embedStaticHtml.replace(this.EMBED_PLACEHOLDER, url);
+ default:
+ return '';
+ }
+ }
+
+ copyEmbedToClipboard(url: string, embedType?: string): boolean {
+ const embedHtml = this.getEmbedHtml(url, embedType);
+
+ if (!embedHtml) {
+ return false;
+ }
+
+ const copied = this.clipboard.copy(embedHtml);
+
+ if (copied) {
+ this.toastService.showSuccess('files.detail.toast.copiedToClipboard');
+ }
+
+ return copied;
+ }
+}
diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts
index ad78cb023..ad2b9d199 100644
--- a/src/app/shared/services/files.service.ts
+++ b/src/app/shared/services/files.service.ts
@@ -6,19 +6,20 @@ import { inject, Injectable } from '@angular/core';
import { ENVIRONMENT } from '@core/provider/environment.provider';
import { MapFileCustomMetadata, MapFileRevision } from '@osf/features/files/mappers';
+import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model';
+import { OsfFileRevision } from '@osf/features/files/models/file-revisions.model';
+import { GetCustomMetadataResponse } from '@osf/features/files/models/get-custom-metadata-response.model';
import {
FileCustomMetadata,
- GetCustomMetadataResponse,
GetFileMetadataResponse,
- GetFileRevisionsResponse,
- GetShortInfoResponse,
- OsfFileCustomMetadata,
- OsfFileRevision,
- PatchFileMetadata,
-} from '@osf/features/files/models';
+} from '@osf/features/files/models/get-file-metadata-response.model';
+import { GetFileRevisionsResponse } from '@osf/features/files/models/get-file-revisions-response.model';
+import { GetShortInfoResponse } from '@osf/features/files/models/get-short-info-response.model';
+import { PatchFileMetadata } from '@osf/features/files/models/patch-file-metadata.model';
import { PaginatedData } from '@osf/shared/models/paginated-data.model';
import { FileKind } from '../enums/file-kind.enum';
+import { ResourceType } from '../enums/resource-type.enum';
import { AddonMapper } from '../mappers/addon.mapper';
import { ContributorsMapper } from '../mappers/contributors';
import { FilesMapper } from '../mappers/files/files.mapper';
@@ -36,6 +37,7 @@ import {
FileFoldersResponseJsonApi,
} from '../models/files/file-folder-json-api.model';
import {
+ FileDetailsDataJsonApi,
FileDetailsResponseJsonApi,
FileResponseJsonApi,
FilesResponseJsonApi,
@@ -60,7 +62,13 @@ export class FilesService {
return this.environment.addonsApiUrl;
}
- filesFields = 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files';
+ private readonly filesFields = 'name,guid,kind,extra,size,path,materialized_path,date_modified,parent_folder,files';
+
+ private readonly resourcePathMap: Record = {
+ [ResourceType.Project]: 'nodes',
+ [ResourceType.Registration]: 'registrations',
+ [ResourceType.Preprint]: 'preprints',
+ };
getFiles(
filesLink: string,
@@ -86,10 +94,17 @@ export class FilesService {
.pipe(map((response) => ({ files: FilesMapper.getFileFolders(response.data), meta: response.meta })));
}
+ getRootFolders(
+ resourceId: string,
+ resourceType: ResourceType
+ ): Observable<{ files: FileFolderModel[]; meta?: MetaJsonApi }> {
+ const resourcePath = this.resourcePathMap[resourceType];
+ return this.getFolders(`${this.apiUrl}/${resourcePath}/${resourceId}/files/`);
+ }
+
getFilesWithoutFiltering(filesLink: string, page = 1): Observable> {
- const params: Record = {
- page: page.toString(),
- };
+ const params: Record = { page: page.toString() };
+
return this.jsonApiService.get(filesLink, params).pipe(
map((response) => ({
data: FilesMapper.getFiles(response.data),
@@ -169,9 +184,7 @@ export class FilesService {
}
getFileGuid(id: string): Observable {
- const params = {
- create_guid: 'true',
- };
+ const params = { create_guid: 'true' };
return this.jsonApiService
.get(`${this.apiUrl}/files/${id}/`, params)
@@ -252,15 +265,13 @@ export class FilesService {
id: fileGuid,
type: 'files',
relationships: {},
- attributes: {
- tags: tags,
- },
+ attributes: { tags: tags },
},
};
return this.jsonApiService
- .patch(`${this.apiUrl}/files/${fileGuid}/`, payload)
- .pipe(map((response) => FilesMapper.getFileDetails(response.data)));
+ .patch(`${this.apiUrl}/files/${fileGuid}/`, payload)
+ .pipe(map((response) => FilesMapper.getFileDetails(response)));
}
copyFileToAnotherLocation(moveLink: string, provider: string, resourceId: string) {
@@ -278,9 +289,7 @@ export class FilesService {
}
getResourceReferences(resourceUri: string): Observable {
- const params = {
- 'filter[resource_uri]': resourceUri,
- };
+ const params = { 'filter[resource_uri]': resourceUri };
return this.jsonApiService
.get<
@@ -289,7 +298,9 @@ export class FilesService {
.pipe(map((response) => response.data?.[0]?.links?.self ?? ''));
}
- getConfiguredStorageAddons(resourceUri: string): Observable {
+ getConfiguredStorageAddons(resourceId: string): Observable {
+ const resourceUri = `${this.environment.webUrl}/${resourceId}`;
+
return this.getResourceReferences(resourceUri).pipe(
switchMap((referenceUrl: string) => {
if (!referenceUrl) return of([]);
diff --git a/src/app/shared/services/meta-tags-builder.service.spec.ts b/src/app/shared/services/meta-tags-builder.service.spec.ts
index 14644b63d..59745be75 100644
--- a/src/app/shared/services/meta-tags-builder.service.spec.ts
+++ b/src/app/shared/services/meta-tags-builder.service.spec.ts
@@ -4,7 +4,7 @@ import { MockProvider } from 'ng-mocks';
import { LOCALE_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { OsfFileCustomMetadata } from '@osf/features/files/models';
+import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model';
import { FileKind } from '@osf/shared/enums/file-kind.enum';
import { FileDetailsModel } from '@osf/shared/models/files/file.model';
diff --git a/src/app/shared/services/meta-tags-builder.service.ts b/src/app/shared/services/meta-tags-builder.service.ts
index 28dd524f4..31e8f8db7 100644
--- a/src/app/shared/services/meta-tags-builder.service.ts
+++ b/src/app/shared/services/meta-tags-builder.service.ts
@@ -4,7 +4,7 @@ import { formatDate } from '@angular/common';
import { inject, Injectable, LOCALE_ID } from '@angular/core';
import { ENVIRONMENT } from '@core/provider/environment.provider';
-import { OsfFileCustomMetadata } from '@osf/features/files/models';
+import { OsfFileCustomMetadata } from '@osf/features/files/models/file-custom-metadata.model';
import { PreprintModel } from '@osf/features/preprints/models';
import { ProjectOverviewModel } from '@osf/features/project/overview/models';
import { RegistrationOverviewModel } from '@osf/features/registry/models';
diff --git a/src/app/shared/services/social-share.service.ts b/src/app/shared/services/social-share.service.ts
index 7b821619a..d8c9832f2 100644
--- a/src/app/shared/services/social-share.service.ts
+++ b/src/app/shared/services/social-share.service.ts
@@ -18,10 +18,29 @@ export class SocialShareService {
return this.environment.webUrl;
}
+ getEmailLink(title: string, url: string): string {
+ return this.generateEmailLink({ title, url });
+ }
+
+ getXLink(title: string, url: string): string {
+ return this.generateXLink({ title, url });
+ }
+
+ getFacebookLink(url: string): string {
+ return this.generateFacebookLink({ title: '', url });
+ }
+
+ getFacebookCustomLink(url: string): string {
+ const encodedUrl = encodeURIComponent(url);
+ const appId = this.environment.facebookAppId;
+
+ return `${SOCIAL_SHARE_URLS.facebookShare}?app_id=${appId}&display=popup&href=${encodedUrl}&redirect_uri=${encodedUrl}`;
+ }
+
generateAllSharingLinks(content: SocialShareContentModel): SocialShareLinksModel {
return {
email: this.generateEmailLink(content),
- twitter: this.generateTwitterLink(content),
+ twitter: this.generateXLink(content),
facebook: this.generateFacebookLink(content),
linkedIn: this.generateLinkedInLink(content),
mastodon: this.generateMastodonLink(content),
@@ -58,11 +77,11 @@ export class SocialShareService {
return `${SOCIAL_SHARE_URLS.email}?subject=${subject}&body=${body}`;
}
- private generateTwitterLink(content: SocialShareContentModel): string {
+ private generateXLink(content: SocialShareContentModel): string {
const url = encodeURIComponent(content.url);
const text = encodeURIComponent(content.title);
- return `${SOCIAL_SHARE_URLS.twitter.preview_url}?url=${url}&text=${text}&via=${SOCIAL_SHARE_URLS.twitter.viaHandle}`;
+ return `${SOCIAL_SHARE_URLS.x.preview_url}?url=${url}&text=${text}&via=${SOCIAL_SHARE_URLS.x.viaHandle}`;
}
private generateFacebookLink(content: SocialShareContentModel): string {
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index d398ed471..452a5228a 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -1121,6 +1121,7 @@
"deleteProject": "Delete Project",
"descriptions": {
"file_updated": {
+ "instant": "You'll be notified immediately when files are updated.",
"daily": "You'll receive a daily summary of file updates.",
"instant": "You'll be notified immediately when files are updated.",
"none": "You won't receive file update notifications."
diff --git a/src/styles/overrides/tree.scss b/src/styles/overrides/tree.scss
index e30a4f1e8..86f6f2f96 100644
--- a/src/styles/overrides/tree.scss
+++ b/src/styles/overrides/tree.scss
@@ -2,6 +2,13 @@
.p-tree {
padding: 0;
+ .files-table-row {
+ .p-button {
+ --p-button-label-font-weight: 400;
+ --p-button-link-color: var(--dark-blue-1);
+ }
+ }
+
.p-tree-node-toggle-button {
display: none;
}
@@ -18,7 +25,7 @@
.p-tree-node-dragover {
.files-table-row {
- background: var(--bg-blue-3);
+ background-color: var(--bg-blue-3);
}
}
@@ -47,13 +54,17 @@
}
.p-tree-empty-message {
- display: none;
+ height: 100%;
}
.p-tree-node-selected {
.files-table-row {
color: var(--white);
- background: var(--pr-blue-1);
+ background-color: var(--pr-blue-1);
+
+ .p-button {
+ --p-button-link-color: var(--white);
+ }
.blue-icon {
color: var(--white);
@@ -61,18 +72,4 @@
}
}
}
-
- .empty-state-container {
- position: absolute;
- inset: 0;
- top: 2.75rem;
- display: flex;
- justify-content: center;
- align-items: center;
-
- .drop-text {
- text-align: center;
- margin-bottom: 2.75rem;
- }
- }
}
diff --git a/src/testing/mocks/file-details.mock.ts b/src/testing/mocks/file-details.mock.ts
new file mode 100644
index 000000000..7a7b9a8c2
--- /dev/null
+++ b/src/testing/mocks/file-details.mock.ts
@@ -0,0 +1,43 @@
+import { FileKind } from '@osf/shared/enums/file-kind.enum';
+import { FileDetailsModel } from '@osf/shared/models/files/file.model';
+
+import { MOCK_PROJECT_OVERVIEW } from './project-overview.mock';
+
+export const FileDetailsMock = {
+ simple(overrides: Partial = {}): FileDetailsModel {
+ return {
+ id: 'file-id',
+ guid: 'file-guid',
+ name: 'file-name.pdf',
+ kind: FileKind.File,
+ path: '/file-name.pdf',
+ size: 100,
+ materializedPath: '/file-name.pdf',
+ dateModified: '2024-01-05T00:00:00Z',
+ extra: {
+ hashes: {
+ md5: 'md5',
+ sha256: 'sha256',
+ },
+ downloads: 1,
+ },
+ lastTouched: null,
+ dateCreated: '2024-01-04T00:00:00Z',
+ tags: [],
+ currentVersion: 1,
+ showAsUnviewed: false,
+ links: {
+ info: 'info',
+ move: 'move',
+ upload: 'upload',
+ delete: 'delete',
+ download: 'download',
+ render: 'render',
+ html: 'html',
+ self: 'self',
+ },
+ target: MOCK_PROJECT_OVERVIEW,
+ ...overrides,
+ };
+ },
+};