diff --git a/package.json b/package.json index 3aa7c04ad3..8c416ee4e1 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,8 @@ "sortablejs": "1.10.1", "tslib": "^2.0.0", "webfontloader": "1.6.28", - "zone.js": "^0.10.3" + "zone.js": "^0.10.3", + "@kolkov/ngx-gallery": "^1.2.3" }, "devDependencies": { "@angular-builders/custom-webpack": "10.0.1", diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index af95411cef..1c6cd83e9c 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -27,6 +27,10 @@ import { JournalEntitiesModule } from '../entity-groups/journal-entities/journal import { ResearchEntitiesModule } from '../entity-groups/research-entities/research-entities.module'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { ThemedFullItemPageComponent } from './full/themed-full-item-page.component'; +import { MediaViewerComponent } from './media-viewer/media-viewer.component'; +import { MediaViewerVideoComponent } from './media-viewer/media-viewer-video/media-viewer-video.component'; +import { MediaViewerImageComponent } from './media-viewer/media-viewer-image/media-viewer-image.component'; +import { NgxGalleryModule } from '@kolkov/ngx-gallery'; const ENTRY_COMPONENTS = [ // put only entry components that use custom decorator @@ -54,6 +58,9 @@ const DECLARATIONS = [ ItemComponent, UploadBitstreamComponent, AbstractIncrementalListComponent, + MediaViewerComponent, + MediaViewerVideoComponent, + MediaViewerImageComponent ]; @NgModule({ @@ -64,7 +71,8 @@ const DECLARATIONS = [ EditItemPageModule, StatisticsModule.forRoot(), JournalEntitiesModule.withEntryComponents(), - ResearchEntitiesModule.withEntryComponents() + ResearchEntitiesModule.withEntryComponents(), + NgxGalleryModule, ], declarations: [ ...DECLARATIONS diff --git a/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.html b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.html new file mode 100644 index 0000000000..bafc6f079c --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss new file mode 100644 index 0000000000..72ce4b04d9 --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.scss @@ -0,0 +1,6 @@ +.ngx-gallery { + display: inline-block; + margin-bottom: 20px; + width: 340px !important; + height: 279px !important; +} diff --git a/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.spec.ts b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.spec.ts new file mode 100644 index 0000000000..1f1fed789f --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.spec.ts @@ -0,0 +1,89 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NgxGalleryOptions } from '@kolkov/ngx-gallery'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { MockBitstreamFormat1 } from '../../../shared/mocks/item.mock'; + +import { MediaViewerImageComponent } from './media-viewer-image.component'; + +import { of as observableOf } from 'rxjs'; +import { AuthService } from '../../../core/auth/auth.service'; + +describe('MediaViewerImageComponent', () => { + let component: MediaViewerImageComponent; + let fixture: ComponentFixture; + + const authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(false) + }); + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10201, + content: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + }, + content: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + }, + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx', + }, + ], + }, + }); + + const mockMediaViewerItems: MediaViewerItem[] = Object.assign( + new Array(), + [ + { bitstream: mockBitstream, format: 'image', thumbnail: null }, + { bitstream: mockBitstream, format: 'image', thumbnail: null }, + ] + ); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports:[], + declarations: [MediaViewerImageComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: AuthService, useValue: authService }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MediaViewerImageComponent); + component = fixture.componentInstance; + component.galleryOptions = [new NgxGalleryOptions({})]; + component.galleryImages = component.convertToGalleryImage( + mockMediaViewerItems + ); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should contain a gallery options', () => { + expect(component.galleryOptions.length).toBeGreaterThan(0); + }); + + it('should contain an image array', () => { + expect(component.galleryImages.length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts new file mode 100644 index 0000000000..0c32b5603d --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-image/media-viewer-image.component.ts @@ -0,0 +1,88 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { NgxGalleryImage, NgxGalleryOptions } from '@kolkov/ngx-gallery'; +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { NgxGalleryAnimation } from '@kolkov/ngx-gallery'; +import { Observable } from 'rxjs'; +import { AuthService } from '../../../core/auth/auth.service'; + +/** + * This componenet render an image gallery for the image viewer + */ +@Component({ + selector: 'ds-media-viewer-image', + templateUrl: './media-viewer-image.component.html', + styleUrls: ['./media-viewer-image.component.scss'], +}) +export class MediaViewerImageComponent implements OnInit { + @Input() images: MediaViewerItem[]; + @Input() preview?: boolean; + @Input() image?: string; + + loggedin: boolean; + + galleryOptions: NgxGalleryOptions[]; + galleryImages: NgxGalleryImage[]; + + /** + * Whether or not the current user is authenticated + */ + isAuthenticated$: Observable; + + constructor(private authService: AuthService) {} + + /** + * Thi method sets up the gallery settings and data + */ + ngOnInit(): void { + this.isAuthenticated$ = this.authService.isAuthenticated(); + this.galleryOptions = [ + { + preview: this.preview !== undefined ? this.preview : true, + image: true, + imageSize: 'contain', + thumbnails: false, + imageArrows: false, + startIndex: 0, + imageAnimation: NgxGalleryAnimation.Slide, + previewCloseOnEsc: true, + previewZoom: true, + previewRotate: true, + previewFullscreen: true, + }, + ]; + + if (this.image) { + this.galleryImages = [ + { + small: this.image, + medium: this.image, + big: this.image, + }, + ]; + } else { + this.galleryImages = this.convertToGalleryImage(this.images); + } + } + + /** + * This method convert an array of MediaViewerItem into NgxGalleryImage array + * @param medias input NgxGalleryImage array + */ + convertToGalleryImage(medias: MediaViewerItem[]): NgxGalleryImage[] { + const mappadImages = []; + for (const image of medias) { + if (image.format === 'image') { + mappadImages.push({ + small: image.thumbnail + ? image.thumbnail + : './assets/images/replacement_image.svg', + medium: image.thumbnail + ? image.thumbnail + : './assets/images/replacement_image.svg', + big: image.bitstream._links.content.href, + }); + } + } + return mappadImages; + } +} diff --git a/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.html b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.html new file mode 100644 index 0000000000..a4493e36fc --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.html @@ -0,0 +1,47 @@ + +
+ + + +
+ +
+ +
+
+
diff --git a/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss new file mode 100644 index 0000000000..7702da7361 --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.scss @@ -0,0 +1,4 @@ +video { + width: 340px; + height: 279px; +} diff --git a/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.spec.ts b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.spec.ts new file mode 100644 index 0000000000..88138a252f --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.spec.ts @@ -0,0 +1,145 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; +import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; +import { FileSizePipe } from '../../../shared/utils/file-size-pipe'; +import { VarDirective } from '../../../shared/utils/var.directive'; +import { MetadataFieldWrapperComponent } from '../../field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import { MockBitstreamFormat1 } from '../../../shared/mocks/item.mock'; +import { MediaViewerVideoComponent } from './media-viewer-video.component'; +import { By } from '@angular/platform-browser'; + +describe('MediaViewerVideoComponent', () => { + let component: MediaViewerVideoComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + ], + declarations: [ + MediaViewerVideoComponent, + VarDirective, + FileSizePipe, + MetadataFieldWrapperComponent, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10201, + content: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + }, + content: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + }, + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx', + }, + ], + }, + }); + + const mockMediaViewerItems: MediaViewerItem[] = Object.assign( + new Array(), + [ + { bitstream: mockBitstream, format: 'video', thumbnail: null }, + { bitstream: mockBitstream, format: 'video', thumbnail: null }, + ] + ); + const mockMediaViewerItem: MediaViewerItem[] = Object.assign( + new Array(), + [{ bitstream: mockBitstream, format: 'video', thumbnail: null }] + ); + + beforeEach(() => { + fixture = TestBed.createComponent(MediaViewerVideoComponent); + component = fixture.componentInstance; + component.medias = mockMediaViewerItem; + component.filteredMedias = mockMediaViewerItem; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('should show controller buttons when the having mode then one video', () => { + beforeEach(() => { + component.medias = mockMediaViewerItems; + component.filteredMedias = mockMediaViewerItems; + fixture.detectChanges(); + }); + + it('should show buttons', () => { + const controllerButtons = fixture.debugElement.query(By.css('.buttons')); + expect(controllerButtons).toBeTruthy(); + }); + + describe('when the "Next" button is clicked', () => { + beforeEach(() => { + component.currentIndex = 0; + fixture.detectChanges(); + }); + + it('should increase the index', () => { + const viewMore = fixture.debugElement.query(By.css('.next')); + viewMore.triggerEventHandler('click', null); + expect(component.currentIndex).toBe(1); + }); + }); + + describe('when the "Previous" button is clicked', () => { + beforeEach(() => { + component.currentIndex = 1; + fixture.detectChanges(); + }); + + it('should decrease the index', () => { + const viewMore = fixture.debugElement.query(By.css('.previous')); + viewMore.triggerEventHandler('click', null); + expect(component.currentIndex).toBe(0); + }); + }); + + describe('when the "Playlist element" button is clicked', () => { + beforeEach(() => { + component.isCollapsed = true; + fixture.detectChanges(); + }); + + it('should set the the index with the selected one', () => { + const viewMore = fixture.debugElement.query(By.css('.list-element')); + viewMore.triggerEventHandler('click', null); + expect(component.currentIndex).toBe(0); + }); + }); + }); +}); diff --git a/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts new file mode 100644 index 0000000000..4c578a51bb --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer-video/media-viewer-video.component.ts @@ -0,0 +1,55 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MediaViewerItem } from '../../../core/shared/media-viewer-item.model'; + +/** + * This componenet renders a video viewer and playlist for the media viewer + */ +@Component({ + selector: 'ds-media-viewer-video', + templateUrl: './media-viewer-video.component.html', + styleUrls: ['./media-viewer-video.component.scss'], +}) +export class MediaViewerVideoComponent implements OnInit { + @Input() medias: MediaViewerItem[]; + + filteredMedias: MediaViewerItem[]; + + isCollapsed: boolean; + currentIndex = 0; + + replacements = { + video: './assets/images/replacement_video.svg', + audio: './assets/images/replacement_audio.svg', + }; + + replacementThumbnail: string; + + ngOnInit() { + this.isCollapsed = false; + this.filteredMedias = this.medias.filter( + (media) => media.format === 'audio' || media.format === 'video' + ); + } + + /** + * This method sets the reviced index into currentIndex + * @param index Selected index + */ + selectedMedia(index: number) { + this.currentIndex = index; + } + + /** + * This method increade the number of the currentIndex + */ + nextMedia() { + this.currentIndex++; + } + + /** + * This method decrese the number of the currentIndex + */ + prevMedia() { + this.currentIndex--; + } +} diff --git a/src/app/+item-page/media-viewer/media-viewer.component.html b/src/app/+item-page/media-viewer/media-viewer.component.html new file mode 100644 index 0000000000..b79b91629f --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer.component.html @@ -0,0 +1,36 @@ + + +
+ + + + + + + + + + + + + +
+
diff --git a/src/app/+item-page/media-viewer/media-viewer.component.scss b/src/app/+item-page/media-viewer/media-viewer.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer.component.scss @@ -0,0 +1 @@ + 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 new file mode 100644 index 0000000000..ebea703ec8 --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer.component.spec.ts @@ -0,0 +1,143 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { createPaginatedList } from '../../shared/testing/utils.test'; +import { of as observableOf } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { MediaViewerComponent } from './media-viewer.component'; +import { MockBitstreamFormat1 } from '../../shared/mocks/item.mock'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { MediaViewerItem } from '../../core/shared/media-viewer-item.model'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { MetadataFieldWrapperComponent } from '../field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import { FileSizePipe } from '../../shared/utils/file-size-pipe'; + +describe('MediaViewerComponent', () => { + let comp: MediaViewerComponent; + let fixture: ComponentFixture; + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 10201, + content: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', + }, + content: { + href: + 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + }, + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx', + }, + ], + }, + }); + + const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findAllByItemAndBundleName: createSuccessfulRemoteDataObject$( + createPaginatedList([mockBitstream]) + ), + }); + + const mockMediaViewerItem: MediaViewerItem = Object.assign( + new MediaViewerItem(), + { bitstream: mockBitstream, format: 'image', thumbnail: null } + ); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock, + }, + }), + BrowserAnimationsModule, + ], + declarations: [ + MediaViewerComponent, + VarDirective, + FileSizePipe, + MetadataFieldWrapperComponent, + ], + providers: [ + { provide: BitstreamDataService, useValue: bitstreamDataService }, + ], + + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MediaViewerComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('when the bitstreams are loading', () => { + beforeEach(() => { + comp.mediaList$.next([mockMediaViewerItem]); + comp.videoOptions = true; + comp.isLoading = true; + fixture.detectChanges(); + }); + + it('should call the createMediaViewerItem', () => { + const mediaItem = comp.createMediaViewerItem( + mockBitstream, + MockBitstreamFormat1, + undefined + ); + expect(mediaItem).toBeTruthy(); + expect(mediaItem.thumbnail).toBe(null); + }); + + it('should display a loading component', () => { + const loading = fixture.debugElement.query(By.css('ds-loading')); + expect(loading.nativeElement).toBeDefined(); + }); + }); + + describe('when the bitstreams loading is failed', () => { + beforeEach(() => { + comp.mediaList$.next([]); + comp.videoOptions = true; + comp.isLoading = false; + fixture.detectChanges(); + }); + + it('should call the createMediaViewerItem', () => { + const mediaItem = comp.createMediaViewerItem( + mockBitstream, + MockBitstreamFormat1, + undefined + ); + expect(mediaItem).toBeTruthy(); + expect(mediaItem.thumbnail).toBe(null); + }); + + it('should display a default, thumbnail', () => { + const defaultThumbnail = fixture.debugElement.query( + By.css('ds-media-viewer-image') + ); + expect(defaultThumbnail.nativeElement).toBeDefined(); + }); + }); +}); diff --git a/src/app/+item-page/media-viewer/media-viewer.component.ts b/src/app/+item-page/media-viewer/media-viewer.component.ts new file mode 100644 index 0000000000..3f9de8ed3e --- /dev/null +++ b/src/app/+item-page/media-viewer/media-viewer.component.ts @@ -0,0 +1,114 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; +import { BitstreamDataService } from '../../core/data/bitstream-data.service'; +import { PaginatedList } from '../../core/data/paginated-list.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; +import { Bitstream } from '../../core/shared/bitstream.model'; +import { Item } from '../../core/shared/item.model'; +import { MediaViewerItem } from '../../core/shared/media-viewer-item.model'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { hasValue } from '../../shared/empty.util'; +import { followLink } from '../../shared/utils/follow-link-config.model'; + +/** + * This componenet renders the media viewers + */ + +@Component({ + selector: 'ds-media-viewer', + templateUrl: './media-viewer.component.html', + styleUrls: ['./media-viewer.component.scss'], +}) +export class MediaViewerComponent implements OnInit { + @Input() item: Item; + @Input() videoOptions: boolean; + + mediaList$: BehaviorSubject; + + isLoading: boolean; + + thumbnailPlaceholder = './assets/images/replacement_document.svg'; + + constructor(protected bitstreamDataService: BitstreamDataService) {} + + /** + * This metod loads all the Bitstreams and Thumbnails and contert it to media item + */ + ngOnInit(): void { + this.mediaList$ = new BehaviorSubject([]); + this.isLoading = true; + this.loadRemoteData('ORIGINAL').subscribe((bitstreamsRD) => { + if (bitstreamsRD.payload.page.length === 0) { + this.isLoading = false; + this.mediaList$.next([]); + } else { + this.loadRemoteData('THUMBNAIL').subscribe((thumbnailsRD) => { + for ( + let index = 0; + index < bitstreamsRD.payload.page.length; + index++ + ) { + bitstreamsRD.payload.page[index].format + .pipe(getFirstSucceededRemoteDataPayload()) + .subscribe((format) => { + const current = this.mediaList$.getValue(); + const mediaItem = this.createMediaViewerItem( + bitstreamsRD.payload.page[index], + format, + thumbnailsRD.payload && thumbnailsRD.payload.page[index] + ); + this.mediaList$.next([...current, mediaItem]); + }); + } + this.isLoading = false; + }); + } + }); + } + + /** + * This method will retrieve the next page of Bitstreams from the external BitstreamDataService call. + * @param bundleName Bundle name + */ + loadRemoteData( + bundleName: string + ): Observable>> { + return this.bitstreamDataService + .findAllByItemAndBundleName( + this.item, + bundleName, + {}, + true, + true, + followLink('format') + ) + .pipe( + filter( + (bitstreamsRD: RemoteData>) => + hasValue(bitstreamsRD) && + (hasValue(bitstreamsRD.errorMessage) || hasValue(bitstreamsRD.payload)) + ), + take(1) + ); + } + + /** + * This method create MediaViewerItem from incoming bitstreams + * @param original original remote data bitstream + * @param format original bitstream format + * @param thumbnail trunbnail remote data bitstream + */ + createMediaViewerItem( + original: Bitstream, + format: BitstreamFormat, + thumbnail: Bitstream + ): MediaViewerItem { + const mediaItem = new MediaViewerItem(); + mediaItem.bitstream = original; + mediaItem.format = format.mimetype.split('/')[0]; + mediaItem.thumbnail = thumbnail ? thumbnail._links.content.href : null; + return mediaItem; + } +} 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 fc8d06ac10..a004712e0f 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 @@ -8,9 +8,14 @@
- - - + + + + + + + + diff --git a/src/app/+item-page/simple/item-types/shared/item.component.ts b/src/app/+item-page/simple/item-types/shared/item.component.ts index a8119c8565..120eda930f 100644 --- a/src/app/+item-page/simple/item-types/shared/item.component.ts +++ b/src/app/+item-page/simple/item-types/shared/item.component.ts @@ -1,5 +1,6 @@ import { Component, Input, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; +import { environment } from '../../../../../environments/environment'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; @@ -20,6 +21,7 @@ export class ItemComponent implements OnInit { * Route to the item page */ itemPageRoute: string; + mediaViewer = environment.mediaViewer; constructor(protected bitstreamDataService: BitstreamDataService) { } diff --git a/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html b/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html index 241696f688..7a1366dda9 100644 --- a/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html +++ b/src/app/+item-page/simple/item-types/untyped-item/untyped-item.component.html @@ -8,9 +8,14 @@
- - - + + + + + + + + diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 31de304665..7b9a08de92 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -102,7 +102,7 @@ export class AuthInterceptor implements HttpInterceptor { private parseLocation(header: string): string { let location = header.trim(); location = location.replace('location="', ''); - location = location.replace('"', ''); + location = location.replace('"', ''); /* lgtm [js/incomplete-sanitization] */ let re = /%3A%2F%2F/g; location = location.replace(re, '://'); re = /%3A/g; diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index fdfe1c9fac..5f2f123f01 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -326,6 +326,25 @@ describe('RegistryService', () => { }); }); + describe('when createMetadataField is called with a blank qualifier', () => { + let result: Observable; + let metadataField: MetadataField; + + beforeEach(() => { + metadataField = mockFieldsList[0]; + metadataField.qualifier = ''; + result = registryService.createMetadataField(metadataField, mockSchemasList[0]); + }); + + it('should return the created metadata field with a null qualifier', (done) => { + metadataField.qualifier = null; + result.subscribe((field: MetadataField) => { + expect(field).toEqual(metadataField); + done(); + }); + }); + }); + describe('when updateMetadataField is called', () => { let result: Observable; @@ -341,6 +360,25 @@ describe('RegistryService', () => { }); }); + describe('when updateMetadataField is called with a blank qualifier', () => { + let result: Observable; + let metadataField: MetadataField; + + beforeEach(() => { + metadataField = mockFieldsList[0]; + metadataField.qualifier = ''; + result = registryService.updateMetadataField(metadataField); + }); + + it('should return the updated metadata field with a null qualifier', (done) => { + metadataField.qualifier = null; + result.subscribe((field: MetadataField) => { + expect(field).toEqual(metadataField); + done(); + }); + }); + }); + describe('when deleteMetadataSchema is called', () => { let result: Observable>; diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 9ac849bdd3..b7b35c6a5a 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -245,6 +245,9 @@ export class RegistryService { * @param schema The MetadataSchema to create the field in */ public createMetadataField(field: MetadataField, schema: MetadataSchema): Observable { + if (!field.qualifier) { + field.qualifier = null; + } return this.metadataFieldService.create(field, new RequestParam('schemaId', schema.id)).pipe( getFirstSucceededRemoteDataPayload(), hasValueOperator(), @@ -260,6 +263,9 @@ export class RegistryService { * @param field The MetadataField to update */ public updateMetadataField(field: MetadataField): Observable { + if (!field.qualifier) { + field.qualifier = null; + } return this.metadataFieldService.put(field).pipe( getFirstSucceededRemoteDataPayload(), hasValueOperator(), diff --git a/src/app/core/shared/media-viewer-item.model.ts b/src/app/core/shared/media-viewer-item.model.ts new file mode 100644 index 0000000000..cd3a31bd0b --- /dev/null +++ b/src/app/core/shared/media-viewer-item.model.ts @@ -0,0 +1,21 @@ +import { Bitstream } from './bitstream.model'; + +/** + * Model representing a media viewer item + */ +export class MediaViewerItem { + /** + * Incoming Bitsream + */ + bitstream: Bitstream; + + /** + * Incoming Bitsream format type + */ + format: string; + + /** + * Incoming Bitsream thumbnail + */ + thumbnail: string; +} diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index ec0b3dd3ba..bc91d0585e 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -20,6 +20,7 @@ const dcTitle0 = mdValue('Title 0'); const dcTitle1 = mdValue('Title 1'); const dcTitle2 = mdValue('Title 2', 'en_US'); const bar = mdValue('Bar'); +const test = mdValue('Test'); const singleMap = { 'dc.title': [dcTitle0] }; @@ -30,6 +31,11 @@ const multiMap = { 'foo': [bar] }; +const regexTestMap = { + 'foolbar.baz': [test], + 'foo.bard': [test], +}; + const multiViewModelList = [ { key: 'dc.description', ...dcDescription, order: 0 }, { key: 'dc.description.abstract', ...dcAbstract, order: 0 }, @@ -98,6 +104,9 @@ describe('Metadata', () => { testAll([multiMap, singleMap], 'dc.*', [dcDescription, dcAbstract, dcTitle1, dcTitle2]); testAll([multiMap, singleMap], ['dc.title', 'dc.*'], [dcTitle1, dcTitle2, dcDescription, dcAbstract]); }); + describe('with regexTestMap', () => { + testAll(regexTestMap, 'foo.bar.*', []); + }); }); describe('allValues method', () => { diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 612fba1d4a..3fbeb205d2 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -156,7 +156,7 @@ export class Metadata { const outputKeys: string[] = []; for (const inputKey of inputKeys) { if (inputKey.includes('*')) { - const inputKeyRegex = new RegExp('^' + inputKey.replace('.', '\.').replace('*', '.*') + '$'); + const inputKeyRegex = new RegExp('^' + inputKey.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$'); for (const mapKey of Object.keys(mdMap)) { if (!outputKeys.includes(mapKey) && inputKeyRegex.test(mapKey)) { outputKeys.push(mapKey); diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index ee93ebb59a..4abf57f258 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -2547,6 +2547,13 @@ "publication.search.results.head": "Publication Search Results", "publication.search.title": "DSpace Angular :: Publication Search", + + + "media-viewer.next": "Next", + + "media-viewer.previous": "Previous", + + "media-viewer.playlist": "Playlist", "register-email.title": "New user registration", diff --git a/src/assets/images/replacement_audio.svg b/src/assets/images/replacement_audio.svg new file mode 100644 index 0000000000..03899914c9 --- /dev/null +++ b/src/assets/images/replacement_audio.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/replacement_document.svg b/src/assets/images/replacement_document.svg new file mode 100644 index 0000000000..f684ca9597 --- /dev/null +++ b/src/assets/images/replacement_document.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/replacement_image.svg b/src/assets/images/replacement_image.svg new file mode 100644 index 0000000000..b6817cf7fc --- /dev/null +++ b/src/assets/images/replacement_image.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/replacement_video.svg b/src/assets/images/replacement_video.svg new file mode 100644 index 0000000000..1972b25eca --- /dev/null +++ b/src/assets/images/replacement_video.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index c197a1407c..8ca11e4a8c 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -12,6 +12,7 @@ import { CollectionPageConfig } from './collection-page-config.interface'; import { ThemeConfig } from './theme.model'; import { AuthConfig } from './auth-config.interfaces'; import { UIServerConfig } from './ui-server-config.interface'; +import { MediaViewerConfig } from './media-viewer-config.interface'; export interface GlobalConfig extends Config { ui: UIServerConfig; @@ -32,4 +33,5 @@ export interface GlobalConfig extends Config { collection: CollectionPageConfig; themes: ThemeConfig[]; rewriteDownloadUrls: boolean; + mediaViewer: MediaViewerConfig; } diff --git a/src/config/media-viewer-config.interface.ts b/src/config/media-viewer-config.interface.ts new file mode 100644 index 0000000000..8a6a163872 --- /dev/null +++ b/src/config/media-viewer-config.interface.ts @@ -0,0 +1,6 @@ +import { Config } from './config.interface'; + +export interface MediaViewerConfig extends Config { + image: boolean; + video: boolean; +} diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index c6ae9858e5..b8248890fc 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -263,4 +263,11 @@ export const environment: GlobalConfig = { ], // Whether the UI should rewrite file download URLs to match its domain. Only necessary to enable when running UI and REST API on separate domains rewriteDownloadUrls: false, + // Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with "image" or "video"). + // For images, this enables a gallery viewer where you can zoom or page through images. + // For videos, this enables embedded video streaming + mediaViewer: { + image: false, + video: false, + }, }; diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index 4f321c01d4..8de5b187ad 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -217,4 +217,8 @@ export const environment: Partial = { name: 'base', }, ], + mediaViewer: { + image: true, + video: true + }, }; diff --git a/src/styles/_global-styles.scss b/src/styles/_global-styles.scss index 41c6ceab81..d151bb3ba7 100644 --- a/src/styles/_global-styles.scss +++ b/src/styles/_global-styles.scss @@ -35,10 +35,6 @@ body { margin: 0; } -ds-header-navbar-wrapper { - z-index: var(--ds-nav-z-index); -} - ds-admin-sidebar { position: fixed; z-index: var(--ds-sidebar-z-index); @@ -48,6 +44,19 @@ ds-admin-sidebar { height: 100vh; } +.sticky-top { + z-index: 0; +} + +.media-viewer + .change-gallery + .ngx-gallery + ngx-gallery-preview.ngx-gallery-active { + right: 0; + left: auto; + width: calc(100% - 53px); +} + .ds-submission-reorder-dragging { .ds-hint, button { diff --git a/yarn.lock b/yarn.lock index f042076b0d..c92f8b3154 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1247,6 +1247,11 @@ merge-source-map "^1.1.0" schema-utils "^2.7.0" +"@kolkov/ngx-gallery@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@kolkov/ngx-gallery/-/ngx-gallery-1.2.3.tgz#bb15d4b64a5c03905677aa4ca741835aabe41f43" + integrity sha512-Dpnhwq3DGPSXrNt65gexo+/Smb2L0bne14A0WONN04+racETtcY33fqFvNWfRw5Nvk2Eza+sq95eEA0NbgF/6g== + "@ng-bootstrap/ng-bootstrap@7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-7.0.0.tgz#3bfa62eb52fdb891b1ce693ea11c39127e2d1ab7"