diff --git a/src/app/app.component.ts b/src/app/app.component.ts index afba7d027..983562162 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,18 +4,14 @@ import { TranslateService } from '@ngx-translate/core'; import { DialogService } from 'primeng/dynamicdialog'; -import { filter } from 'rxjs/operators'; - -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, inject, OnInit } from '@angular/core'; +import { Router, RouterOutlet } from '@angular/router'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { ConfirmEmailComponent } from '@shared/components'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; -import { MetaTagsService } from './shared/services'; @Component({ selector: 'osf-root', @@ -26,18 +22,15 @@ import { MetaTagsService } from './shared/services'; providers: [DialogService], }) export class AppComponent implements OnInit { - private readonly destroyRef = inject(DestroyRef); private readonly dialogService = inject(DialogService); private readonly router = inject(Router); private readonly translateService = inject(TranslateService); - private readonly metaTagsService = inject(MetaTagsService); private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails); constructor() { - this.setupMetaTagsCleanup(); effect(() => { if (this.unverifiedEmails().length) { this.showEmailDialog(); @@ -50,15 +43,6 @@ export class AppComponent implements OnInit { this.actions.getEmails(); } - private setupMetaTagsCleanup(): void { - this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((event: NavigationEnd) => this.metaTagsService.clearMetaTagsIfNeeded(event.url)); - } - private showEmailDialog() { this.dialogService.open(ConfirmEmailComponent, { width: '448px', diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index 9e4060419..22d0e2c9b 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -1,6 +1,6 @@ import { createDispatchMap, select, Store } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Menu } from 'primeng/menu'; @@ -9,6 +9,7 @@ import { Tab, TabList, Tabs } from 'primeng/tabs'; import { switchMap } from 'rxjs'; +import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -37,8 +38,9 @@ import { } from '@osf/features/metadata/store'; import { LoadingSpinnerComponent, MetadataTabsComponent, SubHeaderComponent } from '@osf/shared/components'; import { MetadataResourceEnum, ResourceType } from '@osf/shared/enums'; +import { pathJoin } from '@osf/shared/helpers'; import { MetadataTabsModel, OsfFile } from '@osf/shared/models'; -import { CustomConfirmationService, ToastService } from '@osf/shared/services'; +import { CustomConfirmationService, MetaTagsService, ToastService } from '@osf/shared/services'; import { FileKeywordsComponent, @@ -58,6 +60,8 @@ import { GetFileRevisions, } from '../../store'; +import { environment } from 'src/environments/environment'; + @Component({ selector: 'osf-file-detail', imports: [ @@ -80,6 +84,7 @@ import { templateUrl: './file-detail.component.html', styleUrl: './file-detail.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DatePipe], }) export class FileDetailComponent { @HostBinding('class') classes = 'flex flex-column flex-1 w-full h-full'; @@ -91,6 +96,9 @@ export class FileDetailComponent { readonly sanitizer = inject(DomSanitizer); readonly toastService = inject(ToastService); readonly customConfirmationService = inject(CustomConfirmationService); + private readonly metaTags = inject(MetaTagsService); + private readonly datePipe = inject(DatePipe); + private readonly translateService = inject(TranslateService); private readonly actions = createDispatchMap({ getFile: GetFile, @@ -110,8 +118,11 @@ export class FileDetailComponent { isFileLoading = select(FilesSelectors.isOpenedFileLoading); cedarRecords = select(MetadataSelectors.getCedarRecords); cedarTemplates = select(MetadataSelectors.getCedarTemplates); - isAnonymous = select(FilesSelectors.isFilesAnonymous); + fileCustomMetadata = select(FilesSelectors.getFileCustomMetadata); + resourceMetadata = select(FilesSelectors.getResourceMetadata); + resourceContributors = select(FilesSelectors.getContributors); + safeLink: SafeResourceUrl | null = null; resourceId = ''; resourceType = ''; @@ -162,6 +173,33 @@ export class FileDetailComponent { selectedCedarTemplate = signal(null); cedarFormReadonly = signal(true); + private readonly effectMetaTags = effect(() => { + const metaTagsData = this.metaTagsData(); + if (metaTagsData) { + this.metaTags.updateMetaTags(metaTagsData, this.destroyRef); + } + }); + + private readonly metaTagsData = computed(() => { + const file = this.file(); + if (!file) return null; + return { + title: this.fileCustomMetadata()?.title || file.name, + description: + this.fileCustomMetadata()?.description ?? + this.translateService.instant('files.metaTagDescriptionPlaceholder'), + url: pathJoin(environment.webUrl, this.fileGuid), + publishedDate: this.datePipe.transform(file.dateCreated, 'yyyy-MM-dd'), + modifiedDate: this.datePipe.transform(file.dateModified, 'yyyy-MM-dd'), + language: this.fileCustomMetadata()?.language, + contributors: this.resourceContributors()?.map((contributor) => ({ + fullName: contributor.fullName, + givenName: contributor.givenName, + familyName: contributor.familyName, + })), + }; + }); + constructor() { this.route.params .pipe( diff --git a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts index a42138743..be07f7bbd 100644 --- a/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts +++ b/src/app/features/preprints/pages/preprint-details/preprint-details.component.ts @@ -363,25 +363,26 @@ export class PreprintDetailsComponent extends DataciteTrackerComponent implement } private setMetaTags() { - const image = 'engines-dist/registries/assets/img/osf-sharing.png'; - - this.metaTags.updateMetaTags({ - title: this.preprint()?.title, - description: this.preprint()?.description, - publishedDate: this.datePipe.transform(this.preprint()?.datePublished, 'yyyy-MM-dd'), - modifiedDate: this.datePipe.transform(this.preprint()?.dateModified, 'yyyy-MM-dd'), - url: pathJoin(environment.webUrl, this.preprint()?.id ?? ''), - image, - identifier: this.preprint()?.id, - doi: this.preprint()?.doi, - keywords: this.preprint()?.tags, - siteName: 'OSF', - license: this.preprint()?.embeddedLicense?.name, - contributors: this.contributors().map((contributor) => ({ - givenName: contributor.fullName, - familyName: contributor.familyName, - })), - }); + this.metaTags.updateMetaTags( + { + title: this.preprint()?.title, + description: this.preprint()?.description, + publishedDate: this.datePipe.transform(this.preprint()?.datePublished, 'yyyy-MM-dd'), + modifiedDate: this.datePipe.transform(this.preprint()?.dateModified, 'yyyy-MM-dd'), + url: pathJoin(environment.webUrl, this.preprint()?.id ?? ''), + identifier: this.preprint()?.id, + doi: this.preprint()?.doi, + keywords: this.preprint()?.tags, + siteName: 'OSF', + license: this.preprint()?.embeddedLicense?.name, + contributors: this.contributors().map((contributor) => ({ + fullName: contributor.fullName, + givenName: contributor.givenName, + familyName: contributor.familyName, + })), + }, + this.destroyRef, + ); } private hasReadWriteAccess(): boolean { diff --git a/src/app/features/registry/registry.component.spec.ts b/src/app/features/registry/registry.component.spec.ts index 4e46094d3..856c305e4 100644 --- a/src/app/features/registry/registry.component.spec.ts +++ b/src/app/features/registry/registry.component.spec.ts @@ -41,7 +41,7 @@ describe('RegistryComponent', () => { { provide: DataciteService, useValue: dataciteService }, { provide: MetaTagsService, - useValue: { updateMetaTagsForRoute: jest.fn() }, + useValue: { updateMetaTags: jest.fn() }, }, ], }).compileComponents(); diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index 2191f6496..3c3ec004d 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -3,7 +3,7 @@ import { select } from '@ngxs/store'; import { Observable } from 'rxjs'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, HostBinding, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, HostBinding, inject } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { RouterOutlet } from '@angular/router'; @@ -29,6 +29,7 @@ export class RegistryComponent extends DataciteTrackerComponent { private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); + private readonly destroyRef = inject(DestroyRef); readonly registry = select(RegistryOverviewSelectors.getRegistry); readonly registry$ = toObservable(select(RegistryOverviewSelectors.getRegistry)); @@ -48,16 +49,13 @@ export class RegistryComponent extends DataciteTrackerComponent { } private setMetaTags(): void { - const image = 'engines-dist/registries/assets/img/osf-sharing.png'; - - this.metaTags.updateMetaTagsForRoute( + this.metaTags.updateMetaTags( { title: this.registry()?.title, description: this.registry()?.description, publishedDate: this.datePipe.transform(this.registry()?.dateRegistered, 'yyyy-MM-dd'), modifiedDate: this.datePipe.transform(this.registry()?.dateModified, 'yyyy-MM-dd'), url: pathJoin(environment.webUrl, this.registry()?.id ?? ''), - image, identifier: this.registry()?.id, doi: this.registry()?.doi, keywords: this.registry()?.tags, @@ -65,11 +63,12 @@ export class RegistryComponent extends DataciteTrackerComponent { license: this.registry()?.license?.name, contributors: this.registry()?.contributors?.map((contributor) => ({ + fullName: contributor.fullName, givenName: contributor.givenName, familyName: contributor.familyName, })) ?? [], }, - 'registries' + this.destroyRef, ); } } diff --git a/src/app/shared/models/meta-tags/meta-tag-author.model.ts b/src/app/shared/models/meta-tags/meta-tag-author.model.ts index 519fa17b2..0ed63282e 100644 --- a/src/app/shared/models/meta-tags/meta-tag-author.model.ts +++ b/src/app/shared/models/meta-tags/meta-tag-author.model.ts @@ -1,4 +1,5 @@ export interface MetaTagAuthor { - givenName: string; - familyName: string; + fullName?: string; + givenName?: string; + familyName?: string; } diff --git a/src/app/shared/services/meta-tags.service.ts b/src/app/shared/services/meta-tags.service.ts index d1e37d044..701c75ec0 100644 --- a/src/app/shared/services/meta-tags.service.ts +++ b/src/app/shared/services/meta-tags.service.ts @@ -1,5 +1,5 @@ import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable } from '@angular/core'; +import { DestroyRef, Inject, Injectable } from '@angular/core'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { Content, DataContent, HeadTagDef, MetaTagAuthor, MetaTagsData } from '../models/meta-tags'; @@ -14,7 +14,7 @@ export class MetaTagsService { type: 'article', description: 'Hosted on the OSF', language: 'en-US', - image: `${environment.webUrl}/static/img/preprints_assets/osf/sharing.png`, + image: `${environment.webUrl}/assets/images/osf-sharing.png`, imageType: 'image/png', imageWidth: 1200, imageHeight: 630, @@ -27,7 +27,9 @@ export class MetaTagsService { }; private readonly metaTagClass = 'osf-dynamic-meta'; - private currentRouteGroup: string | null = null; + + // data from all active routed components that set meta tags + private metaTagStack: Array<{ metaTagsData: MetaTagsData; componentDestroyRef: DestroyRef }> = []; constructor( private meta: Meta, @@ -35,17 +37,13 @@ export class MetaTagsService { @Inject(DOCUMENT) private document: Document ) {} - updateMetaTags(metaTagsData: MetaTagsData): void { - const combinedData = { ...this.defaultMetaTags, ...metaTagsData }; - const headTags = this.getHeadTags(combinedData); - - this.applyHeadTags(headTags); - this.dispatchZoteroEvent(); - } - - updateMetaTagsForRoute(metaTagsData: MetaTagsData, routeGroup: string): void { - this.currentRouteGroup = routeGroup; - this.updateMetaTags(metaTagsData); + updateMetaTags(metaTagsData: MetaTagsData, componentDestroyRef: DestroyRef): void { + this.metaTagStack = [...this.metaTagStackWithout(componentDestroyRef), { metaTagsData, componentDestroyRef }]; + componentDestroyRef.onDestroy(() => { + this.metaTagStack = this.metaTagStackWithout(componentDestroyRef); + this.applyNearestMetaTags(); + }); + this.applyNearestMetaTags(); } clearMetaTags(): void { @@ -62,27 +60,28 @@ export class MetaTagsService { }); this.title.setTitle(String(this.defaultMetaTags.siteName)); - this.currentRouteGroup = null; } - shouldClearMetaTags(newUrl: string): boolean { - if (!this.currentRouteGroup) return true; - return !newUrl.startsWith(`/${this.currentRouteGroup}`); + private metaTagStackWithout(destroyRefToRemove: DestroyRef) { + // get a copy of `this.metaTagStack` minus any entries with the given destroyRef + return this.metaTagStack.filter(({ componentDestroyRef }) => componentDestroyRef !== destroyRefToRemove); } - clearMetaTagsIfNeeded(newUrl: string): void { - if (this.shouldClearMetaTags(newUrl)) { + private applyNearestMetaTags() { + // apply the meta tags for the nearest active route that called `updateMetaTags` (if any) + const nearest = this.metaTagStack.at(-1); + if (nearest) { + this.applyMetaTagsData(nearest.metaTagsData); + } else { this.clearMetaTags(); } - } - - resetToDefaults(): void { - this.updateMetaTags({}); - } + }; - getHeadTagsPublic(metaTagsData: MetaTagsData): HeadTagDef[] { + private applyMetaTagsData(metaTagsData: MetaTagsData) { const combinedData = { ...this.defaultMetaTags, ...metaTagsData }; - return this.getHeadTags(combinedData); + const headTags = this.getHeadTags(combinedData); + this.applyHeadTags(headTags); + this.dispatchZoteroEvent(); } private getHeadTags(metaTagsData: MetaTagsData): HeadTagDef[] { @@ -173,6 +172,7 @@ export class MetaTagsService { .filter((person): person is MetaTagAuthor => typeof person === 'object' && person !== null) .map((person) => ({ '@type': 'schema:Person', + name: person.fullName, givenName: person.givenName, familyName: person.familyName, })); diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 38e062ea4..4b5de450a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -956,6 +956,7 @@ "title": "Files", "storageLocation": "OSF Storage", "searchPlaceholder": "Search your projects", + "metaTagDescriptionPlaceholder": "Presented by OSF", "sort": { "placeholder": "Sort", "nameAZ": "Name: A-Z", diff --git a/src/assets/images/osf-sharing.png b/src/assets/images/osf-sharing.png new file mode 100644 index 000000000..4acb27c31 Binary files /dev/null and b/src/assets/images/osf-sharing.png differ