diff --git a/config/config.example.yml b/config/config.example.yml index c3dc673b537..845565388c4 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -471,6 +471,7 @@ bundle: mediaViewer: image: false video: false + pdf: false # Whether the end user agreement is required before users use the repository. # If enabled, the user will be required to accept the agreement before they can use the repository. diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.html b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.html new file mode 100644 index 00000000000..6dc687f2fc9 --- /dev/null +++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.html @@ -0,0 +1,41 @@ +
+ +
+

{{ 'media-viewer.pdf.files' | translate }}

+ + + {{ pdfs?.length || 0 }} + +
+ + + +
+ + @if (isLoading) { +
+
+ Loading... +
+ {{ 'media-viewer.loading' | translate }}... +
+ } + + @else { + +

+ {{ 'media-viewer.pdf.browser-not-support' | translate }} + {{ 'media-viewer.pdf.download' | translate }}. +

+
+ } + +
+
\ No newline at end of file diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.scss b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.scss new file mode 100644 index 00000000000..121516400d0 --- /dev/null +++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.scss @@ -0,0 +1,24 @@ +.pdf-viewer-container { + margin-bottom: 2rem; +} + +.simple-view-element-header { + font-size: 1.25rem; +} + +.pdf-viewer { + height: 50vh; + padding-bottom: 1px; + min-height: 500px; + display: flex; + flex-direction: column; +} + +.loading-overlay { + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + background-color: #f8f9fa; + color: #6c757d; +} \ No newline at end of file diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.spec.ts b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.spec.ts new file mode 100644 index 00000000000..448f89b2de4 --- /dev/null +++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.spec.ts @@ -0,0 +1,176 @@ +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { + ChangeDetectorRef, + NO_ERRORS_SCHEMA, +} from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { + By, + DomSanitizer, +} from '@angular/platform-browser'; +import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service'; +import { Bitstream } from '@dspace/core/shared/bitstream.model'; +import { MediaViewerItem } from '@dspace/core/shared/media-viewer-item.model'; +import { MockBitstreamFormat1 } from '@dspace/core/testing/item.mock'; +import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; +import { + TranslateLoader, + TranslateModule, +} from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { MediaViewerPdfComponent } from './media-viewer-pdf.component'; + +describe('MediaViewerPdfComponent', () => { + let component: MediaViewerPdfComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let sanitizer: DomSanitizer; + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 1024, + format: of(MockBitstreamFormat1), + _links: { + self: { + href: 'https://demo.dspace.org/api/core/bitstreams/123', + }, + content: { + href: 'https://demo.dspace.org/api/core/bitstreams/123/content', + }, + }, + id: '123', + uuid: '123', + type: 'bitstream', + metadata: { + 'dc.title': [ + { language: null, value: 'test_pdf.pdf' }, + ], + }, + }); + + const mockMediaViewerItems: MediaViewerItem[] = [ + { bitstream: mockBitstream, format: 'application', mimetype: 'application/pdf', thumbnail: null, accessToken: null }, + { bitstream: mockBitstream, format: 'application', mimetype: 'application/pdf', thumbnail: null, accessToken: null }, + ]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: TranslateLoaderMock }, + }), + MediaViewerPdfComponent, + ], + providers: [ + DSONameService, + ChangeDetectorRef, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MediaViewerPdfComponent); + component = fixture.componentInstance; + sanitizer = TestBed.inject(DomSanitizer); + httpMock = TestBed.inject(HttpTestingController); + component.pdfs = mockMediaViewerItems; + fixture.detectChanges(); + const initReq = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content'); + initReq.flush(new Blob(['initial content'], { type: 'application/pdf' })); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should call loadPdf with index 0', () => { + spyOn(component, 'loadPdf'); + component.ngOnInit(); + // Dot notation cannot be used because 'loadPdf' is a private method. + // Accessing the private method only for testing purposes. + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(component['loadPdf']).toHaveBeenCalledWith(0); + }); + }); + + describe('loadPdf', () => { + it('should load and set blobUrl on success', () => { + const mockBlob = new Blob(['PDF content'], { type: 'application/pdf' }); + const bypassSpy = spyOn(sanitizer, 'bypassSecurityTrustResourceUrl').and.callThrough(); + + component.ngOnInit(); + const req = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content'); + expect(req.request.method).toBe('GET'); + req.flush(mockBlob); + + expect(component.isLoading).toBeFalse(); + expect(bypassSpy).toHaveBeenCalled(); + expect(component.blobUrl).toBeTruthy(); + }); + + it('should handle errors gracefully', () => { + spyOn(console, 'error'); + component.ngOnInit(); + const req = httpMock.expectOne('https://demo.dspace.org/api/core/bitstreams/123/content'); + req.error(new ProgressEvent('Network error')); + + expect(component.isLoading).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + }); + }); + + describe('selectedMedia', () => { + it('should set currentIndex and load the selected pdf', () => { + const spyLoad = spyOn(component, 'loadPdf'); + component.selectedMedia(1); + expect(component.currentIndex).toBe(1); + expect(spyLoad).toHaveBeenCalledWith(1); + }); + }); + + describe('UI template', () => { + it('should display the select element with the correct number of options', () => { + fixture.detectChanges(); + const selectEl = fixture.debugElement.query(By.css('select')); + expect(selectEl).toBeTruthy(); + + const options = selectEl.nativeElement.querySelectorAll('option'); + expect(options.length).toBe(2); + }); + + it('should show loading overlay when isLoading is true', () => { + component.isLoading = true; + fixture.detectChanges(); + const loadingEl = fixture.debugElement.query(By.css('.loading-overlay')); + expect(loadingEl).toBeTruthy(); + }); + + it('should show pdf object when not loading', () => { + component.isLoading = false; + fixture.detectChanges(); + const objectEl = fixture.debugElement.query(By.css('object')); + expect(objectEl).toBeTruthy(); + }); + }); +}); diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.ts b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.ts new file mode 100644 index 00000000000..54c7ea2b9e9 --- /dev/null +++ b/src/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component.ts @@ -0,0 +1,68 @@ +import { HttpClient } from '@angular/common/http'; +import { + ChangeDetectorRef, + Component, + Input, + OnInit, + ViewChild, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + DomSanitizer, + SafeResourceUrl, +} from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; + +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; + +@Component({ + selector: 'ds-base-media-viewer-pdf', + templateUrl: './media-viewer-pdf.component.html', + styleUrls: ['./media-viewer-pdf.component.scss'], + imports: [ + FormsModule, + TranslateModule, + ], +}) +export class MediaViewerPdfComponent implements OnInit { + @Input() pdfs: MediaViewerItem[]; + @ViewChild('pdfViewer') pdfViewer; + + blobUrl: SafeResourceUrl = this.sanitizer.bypassSecurityTrustResourceUrl(''); + currentIndex = 0; + + isLoading = false; + + constructor(private http: HttpClient, private sanitizer: DomSanitizer, public dsoNameService: DSONameService, private cdr: ChangeDetectorRef) { } + + ngOnInit() { + this.loadPdf(this.currentIndex); + } + + selectedMedia(index: number) { + this.currentIndex = index; + this.loadPdf(index); + } + + private loadPdf(index: number) { + this.isLoading = true; + + const url = this.pdfs[index].bitstream._links.content.href; + + this.http.get(url, { responseType: 'blob' }).subscribe({ + next: (blob) => { + const blobUrl = URL.createObjectURL(blob); + this.blobUrl = this.sanitizer.bypassSecurityTrustResourceUrl(blobUrl); + + this.isLoading = false; + this.cdr.detectChanges(); + }, + error: (err: unknown) => { + console.error('Error loading PDF:', err); + this.isLoading = false; + this.cdr.detectChanges(); + }, + }); + } +} diff --git a/src/app/item-page/media-viewer/media-viewer-pdf/themed-media-viewer-pdf.component.ts b/src/app/item-page/media-viewer/media-viewer-pdf/themed-media-viewer-pdf.component.ts new file mode 100644 index 00000000000..1abfe09f599 --- /dev/null +++ b/src/app/item-page/media-viewer/media-viewer-pdf/themed-media-viewer-pdf.component.ts @@ -0,0 +1,38 @@ +import { + Component, + Input, +} from '@angular/core'; + +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { ThemedComponent } from '../../../shared/theme-support/themed.component'; +import { MediaViewerPdfComponent } from './media-viewer-pdf.component'; + +/** + * Themed wrapper for {@link MediaViewerPdfComponent}. + */ +@Component({ + selector: 'ds-media-viewer-pdf', + styleUrls: [], + templateUrl: '../../../shared/theme-support/themed.component.html', +}) +export class ThemedMediaViewerPdfComponent extends ThemedComponent { + + @Input() pdfs: MediaViewerItem[]; + + protected inAndOutputNames: (keyof MediaViewerPdfComponent & keyof this)[] = [ + 'pdfs', + ]; + + protected getComponentName(): string { + return 'MediaViewerPdfComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../themes/${themeName}/app/item-page/media-viewer/media-viewer-pdf/media-viewer-pdf.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./media-viewer-pdf.component'); + } + +} diff --git a/src/app/item-page/media-viewer/media-viewer.component.html b/src/app/item-page/media-viewer/media-viewer.component.html index 0629ded8ca3..265d9c5536c 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.html +++ b/src/app/item-page/media-viewer/media-viewer.component.html @@ -1,42 +1,42 @@ @if (isLoading) { - + } @else { -
- @if (mediaList.length > 0) { - - - @if (showVideo) { - - } - @if (showImage) { - - } - @if (showImage || showVideo) { - } @else { - @if (mediaOptions.image && mediaOptions.video) { - - } - @if (!(mediaOptions.image && mediaOptions.video)) { - - - } - } - +
+ @if (mediaList.length > 0) { + + + + @if (showPdf) { + + } + + @if (showVideo) { + + } + @if (showImage) { + + } + @if (showImage || showVideo || showPdf) { + } @else { + @if (mediaOptions.image && mediaOptions.video) { + + } + @if (!(mediaOptions.image && mediaOptions.video)) { + + + } + } - } @else { - - - } -
+
+ + } @else { + + + } +
} -
+ \ No newline at end of file diff --git a/src/app/item-page/media-viewer/media-viewer.component.spec.ts b/src/app/item-page/media-viewer/media-viewer.component.spec.ts index c003429c536..992151c55e6 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.spec.ts +++ b/src/app/item-page/media-viewer/media-viewer.component.spec.ts @@ -132,6 +132,7 @@ describe('MediaViewerComponent', () => { comp.mediaOptions = { image: true, video: true, + pdf: true, }; comp.isLoading = true; fixture.detectChanges(); @@ -159,6 +160,7 @@ describe('MediaViewerComponent', () => { comp.mediaOptions = { image: true, video: true, + pdf: true, }; comp.isLoading = false; fixture.detectChanges(); diff --git a/src/app/item-page/media-viewer/media-viewer.component.ts b/src/app/item-page/media-viewer/media-viewer.component.ts index 11b1e6793bc..a22fe5129e1 100644 --- a/src/app/item-page/media-viewer/media-viewer.component.ts +++ b/src/app/item-page/media-viewer/media-viewer.component.ts @@ -35,6 +35,7 @@ import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.comp import { VarDirective } from '../../shared/utils/var.directive'; import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component'; import { ThemedMediaViewerImageComponent } from './media-viewer-image/themed-media-viewer-image.component'; +import { ThemedMediaViewerPdfComponent } from './media-viewer-pdf/themed-media-viewer-pdf.component'; import { ThemedMediaViewerVideoComponent } from './media-viewer-video/themed-media-viewer-video.component'; /** @@ -48,6 +49,7 @@ import { ThemedMediaViewerVideoComponent } from './media-viewer-video/themed-med AsyncPipe, ThemedLoadingComponent, ThemedMediaViewerImageComponent, + ThemedMediaViewerPdfComponent, ThemedMediaViewerVideoComponent, ThemedThumbnailComponent, TranslateModule, @@ -88,10 +90,12 @@ export class MediaViewerComponent implements OnDestroy, OnInit { * This method loads all the Bitstreams and Thumbnails and converts it to {@link MediaViewerItem}s */ ngOnInit(): void { + console.log('MediaViewerComponent init', this.mediaList$); this.itemRequest = this.route.snapshot.data.itemRequest; const types: string[] = [ ...(this.mediaOptions.image ? ['image'] : []), ...(this.mediaOptions.video ? ['audio', 'video'] : []), + ...(this.mediaOptions.pdf ? ['application/pdf'] : []), ]; this.thumbnailsRD$ = this.loadRemoteData('THUMBNAIL'); this.subs.push(this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD: RemoteData>) => { @@ -113,7 +117,7 @@ export class MediaViewerComponent implements OnDestroy, OnInit { format, thumbnailsRD.payload && thumbnailsRD.payload.page[index], ); - if (types.includes(mediaItem.format)) { + if (types.includes(mediaItem.mimetype)) { this.mediaList$.next([...this.mediaList$.getValue(), mediaItem]); } else if (format.mimetype === 'text/vtt') { this.captions$.next([...this.captions$.getValue(), bitstreamsRD.payload.page[index]]); @@ -180,4 +184,9 @@ export class MediaViewerComponent implements OnDestroy, OnInit { return null; } + filterPdf(mediaList: MediaViewerItem[]) { + const pdfs = mediaList.filter(item => item.mimetype === 'application/pdf'); + return pdfs; + } + } diff --git a/src/app/item-page/simple/item-types/publication/publication.component.html b/src/app/item-page/simple/item-types/publication/publication.component.html index a294cc025bc..83989b4a85b 100644 --- a/src/app/item-page/simple/item-types/publication/publication.component.html +++ b/src/app/item-page/simple/item-types/publication/publication.component.html @@ -24,11 +24,6 @@ } - @if (mediaViewer.image || mediaViewer.video) { -
- -
- } + @if (mediaViewer.image || mediaViewer.video || mediaViewer.pdf) { +
+ +
+ } @if (geospatialItemPageFieldsEnabled) { } - @if (mediaViewer.image || mediaViewer.video) { -
- -
- } + @if (mediaViewer.image || mediaViewer.video || mediaViewer.pdf) { +
+ +
+ } @if (geospatialItemPageFieldsEnabled) {