diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html
index 85aeb63a6b..bc16853721 100644
--- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html
+++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item/item-search-result-grid-element.component.html
@@ -6,14 +6,14 @@
diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts
index 4de0f2901e..c5a91bd02c 100644
--- a/src/app/shared/shared.module.ts
+++ b/src/app/shared/shared.module.ts
@@ -46,7 +46,6 @@ import { ThumbnailComponent } from '../thumbnail/thumbnail.component';
import { SearchFormComponent } from './search-form/search-form.component';
import { SearchResultGridElementComponent } from './object-grid/search-result-grid-element/search-result-grid-element.component';
import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component';
-import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component';
import { VarDirective } from './utils/var.directive';
import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component';
import { LogOutComponent } from './log-out/log-out.component';
@@ -54,8 +53,7 @@ import { FormComponent } from './form/form.component';
import { DsDynamicOneboxComponent } from './form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component';
import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component';
import {
- DsDynamicFormControlContainerComponent,
- dsDynamicFormControlMapFn
+ DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn,
} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component';
import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component';
import { DragClickDirective } from './utils/drag-click.directive';
@@ -340,7 +338,6 @@ const COMPONENTS = [
SidebarFilterComponent,
SidebarFilterSelectedOptionComponent,
ThumbnailComponent,
- GridThumbnailComponent,
UploaderComponent,
FileDropzoneNoUploaderComponent,
ItemListPreviewComponent,
diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html
index dbf8f6732c..bf70928392 100644
--- a/src/app/thumbnail/thumbnail.component.html
+++ b/src/app/thumbnail/thumbnail.component.html
@@ -1,4 +1,14 @@
-
-
![]()
+
+
+ text-content
+
+
+
+
+
+
{{ placeholder | translate }}
+
+
+
-
diff --git a/src/app/thumbnail/thumbnail.component.scss b/src/app/thumbnail/thumbnail.component.scss
index e2718bac06..b15238afac 100644
--- a/src/app/thumbnail/thumbnail.component.scss
+++ b/src/app/thumbnail/thumbnail.component.scss
@@ -1,3 +1,35 @@
+.limit-width {
+ max-width: var(--ds-thumbnail-max-width);
+}
+
img {
max-width: 100%;
}
+
+.outer { // .outer/.inner generated ~ https://ratiobuddy.com/
+ position: relative;
+ &:before {
+ display: block;
+ content: "";
+ width: 100%;
+ padding-top: (297 / 210) * 100%; // A4 ratio
+ }
+ > .inner {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+
+ > .thumbnail-placeholder {
+ background: var(--ds-thumbnail-placeholder-background);
+ border: var(--ds-thumbnail-placeholder-border);
+ color: var(--ds-thumbnail-placeholder-color);
+ font-weight: bold;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+ }
+ }
+}
diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts
index 21678c9162..bc9d159750 100644
--- a/src/app/thumbnail/thumbnail.component.spec.ts
+++ b/src/app/thumbnail/thumbnail.component.spec.ts
@@ -1,10 +1,22 @@
-import { DebugElement } from '@angular/core';
+import { DebugElement, Pipe, PipeTransform } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Bitstream } from '../core/shared/bitstream.model';
import { SafeUrlPipe } from '../shared/utils/safe-url-pipe';
-import { THUMBNAIL_PLACEHOLDER, ThumbnailComponent } from './thumbnail.component';
+import { ThumbnailComponent } from './thumbnail.component';
+import { RemoteData } from '../core/data/remote-data';
+import {
+ createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject,
+} from '../shared/remote-data.utils';
+
+// tslint:disable-next-line:pipe-prefix
+@Pipe({ name: 'translate' })
+class MockTranslatePipe implements PipeTransform {
+ transform(key: string): string {
+ return 'TRANSLATED ' + key;
+ }
+}
describe('ThumbnailComponent', () => {
let comp: ThumbnailComponent;
@@ -14,33 +26,18 @@ describe('ThumbnailComponent', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
- declarations: [ThumbnailComponent, SafeUrlPipe]
+ declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ThumbnailComponent);
- comp = fixture.componentInstance; // BannerComponent test instance
+ comp = fixture.componentInstance; // ThumbnailComponent test instance
de = fixture.debugElement.query(By.css('div.thumbnail'));
el = de.nativeElement;
});
- describe('when the thumbnail exists', () => {
- it('should display an image', () => {
- const thumbnail = new Bitstream();
- thumbnail._links = {
- self: { href: 'self.url' },
- bundle: { href: 'bundle.url' },
- format: { href: 'format.url' },
- content: { href: 'content.url' },
- };
- comp.thumbnail = thumbnail;
- fixture.detectChanges();
- const image: HTMLElement = de.query(By.css('img')).nativeElement;
- expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href);
- });
- });
- describe(`when the thumbnail doesn't exist`, () => {
+ const withoutThumbnail = () => {
describe('and there is a default image', () => {
it('should display the default image', () => {
comp.src = 'http://bit.stream';
@@ -48,14 +45,114 @@ describe('ThumbnailComponent', () => {
comp.errorHandler();
expect(comp.src).toBe(comp.defaultImage);
});
+ it('should include the alt text', () => {
+ comp.src = 'http://bit.stream';
+ comp.defaultImage = 'http://default.img';
+ comp.errorHandler();
+ comp.ngOnChanges();
+ fixture.detectChanges();
+ const image: HTMLElement = de.query(By.css('img')).nativeElement;
+ expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
+ });
});
describe('and there is no default image', () => {
it('should display the placeholder', () => {
comp.src = 'http://default.img';
- comp.defaultImage = 'http://default.img';
comp.errorHandler();
- expect(comp.src).toBe(THUMBNAIL_PLACEHOLDER);
+ expect(comp.src).toBe(null);
+
+ comp.ngOnChanges();
+ fixture.detectChanges();
+ const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement;
+ expect(placeholder.innerHTML).toBe('TRANSLATED ' + comp.placeholder);
});
});
+ };
+
+ describe('with thumbnail as Bitstream', () => {
+ let thumbnail: Bitstream;
+ beforeEach(() => {
+ thumbnail = new Bitstream();
+ thumbnail._links = {
+ self: { href: 'self.url' },
+ bundle: { href: 'bundle.url' },
+ format: { href: 'format.url' },
+ content: { href: 'content.url' },
+ };
+ });
+
+ it('should display an image', () => {
+ comp.thumbnail = thumbnail;
+ comp.ngOnChanges();
+ fixture.detectChanges();
+ const image: HTMLElement = de.query(By.css('img')).nativeElement;
+ expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href);
+ });
+
+ it('should include the alt text', () => {
+ comp.thumbnail = thumbnail;
+ comp.ngOnChanges();
+ fixture.detectChanges();
+ const image: HTMLElement = de.query(By.css('img')).nativeElement;
+ expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
+ });
+
+ describe('when there is no thumbnail', () => {
+ withoutThumbnail();
+ });
+ });
+
+ describe('with thumbnail as RemoteData
', () => {
+ let thumbnail: RemoteData;
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ thumbnail = createPendingRemoteDataObject();
+ });
+
+ it('should show a loading animation', () => {
+ comp.thumbnail = thumbnail;
+ comp.ngOnChanges();
+ fixture.detectChanges();
+ expect(de.query(By.css('ds-loading'))).toBeTruthy();
+ });
+ });
+
+ describe('when there is a thumbnail', () => {
+ beforeEach(() => {
+ const bitstream = new Bitstream();
+ bitstream._links = {
+ self: { href: 'self.url' },
+ bundle: { href: 'bundle.url' },
+ format: { href: 'format.url' },
+ content: { href: 'content.url' },
+ };
+ thumbnail = createSuccessfulRemoteDataObject(bitstream);
+ });
+
+ it('should display an image', () => {
+ comp.thumbnail = thumbnail;
+ comp.ngOnChanges();
+ fixture.detectChanges();
+ const image: HTMLElement = de.query(By.css('img')).nativeElement;
+ expect(image.getAttribute('src')).toBe(comp.thumbnail.payload._links.content.href);
+ });
+
+ it('should display the alt text', () => {
+ comp.thumbnail = thumbnail;
+ comp.ngOnChanges();
+ fixture.detectChanges();
+ const image: HTMLElement = de.query(By.css('img')).nativeElement;
+ expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
+ });
+ });
+
+ describe('when there is no thumbnail', () => {
+ beforeEach(() => {
+ thumbnail = createFailedRemoteDataObject();
+ });
+
+ withoutThumbnail();
+ });
});
});
diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts
index 7e981d5fe6..3e122cde78 100644
--- a/src/app/thumbnail/thumbnail.component.ts
+++ b/src/app/thumbnail/thumbnail.component.ts
@@ -1,61 +1,93 @@
-import { Component, Input, OnInit } from '@angular/core';
+import { Component, Input, OnChanges } from '@angular/core';
import { Bitstream } from '../core/shared/bitstream.model';
import { hasValue } from '../shared/empty.util';
-
-/**
- * A fallback placeholder image as a base64 string
- */
-export const THUMBNAIL_PLACEHOLDER = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23FFFFFF%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E';
+import { RemoteData } from '../core/data/remote-data';
/**
* This component renders a given Bitstream as a thumbnail.
* One input parameter of type Bitstream is expected.
- * If no Bitstream is provided, a holderjs image will be rendered instead.
+ * If no Bitstream is provided, a HTML placeholder will be rendered instead.
*/
@Component({
selector: 'ds-thumbnail',
styleUrls: ['./thumbnail.component.scss'],
- templateUrl: './thumbnail.component.html'
+ templateUrl: './thumbnail.component.html',
})
-export class ThumbnailComponent implements OnInit {
-
+export class ThumbnailComponent implements OnChanges {
/**
* The thumbnail Bitstream
*/
- @Input() thumbnail: Bitstream;
+ @Input() thumbnail: Bitstream | RemoteData;
/**
- * The default image, used if the thumbnail isn't set or can't be downloaded
+ * The default image, used if the thumbnail isn't set or can't be downloaded.
+ * If defaultImage is null, a HTML placeholder is used instead.
*/
- @Input() defaultImage? = THUMBNAIL_PLACEHOLDER;
+ @Input() defaultImage? = null;
/**
* The src attribute used in the template to render the image.
*/
- src: string;
+ src: string = null;
/**
- * Initialize the thumbnail.
+ * i18n key of thumbnail alt text
+ */
+ @Input() alt? = 'thumbnail.default.alt';
+
+ /**
+ * i18n key of HTML placeholder text
+ */
+ @Input() placeholder? = 'thumbnail.default.placeholder';
+
+ /**
+ * Limit thumbnail width to --ds-thumbnail-max-width
+ */
+ @Input() limitWidth? = true;
+
+ isLoading: boolean;
+
+ /**
+ * Resolve the thumbnail.
* Use a default image if no actual image is available.
*/
- ngOnInit(): void {
- if (hasValue(this.thumbnail) && hasValue(this.thumbnail._links) && hasValue(this.thumbnail._links.content) && this.thumbnail._links.content.href) {
- this.src = this.thumbnail._links.content.href;
+ ngOnChanges(): void {
+ if (this.thumbnail === undefined || this.thumbnail === null) {
+ return;
+ }
+ if (this.thumbnail instanceof Bitstream) {
+ this.resolveThumbnail(this.thumbnail as Bitstream);
+ } else {
+ const thumbnailRD = this.thumbnail as RemoteData;
+ if (thumbnailRD.isLoading) {
+ this.isLoading = true;
+ } else {
+ this.resolveThumbnail(thumbnailRD.payload as Bitstream);
+ }
+ }
+ }
+
+ private resolveThumbnail(thumbnail: Bitstream): void {
+ if (hasValue(thumbnail) && hasValue(thumbnail._links)
+ && hasValue(thumbnail._links.content)
+ && thumbnail._links.content.href) {
+ this.src = thumbnail._links.content.href;
} else {
this.src = this.defaultImage;
}
+ this.isLoading = false;
}
/**
* Handle image download errors.
* If the image can't be found, use the defaultImage instead.
- * If that also can't be found, use the base64 placeholder.
+ * If that also can't be found, use null to fall back to the HTML placeholder.
*/
errorHandler() {
if (this.src !== this.defaultImage) {
this.src = this.defaultImage;
} else {
- this.src = THUMBNAIL_PLACEHOLDER;
+ this.src = null;
}
}
}
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5
index e398bbe3fc..98fe86dd1b 100644
--- a/src/assets/i18n/en.json5
+++ b/src/assets/i18n/en.json5
@@ -3544,6 +3544,24 @@
+ "thumbnail.default.alt": "Thumbnail Image",
+
+ "thumbnail.default.placeholder": "No Thumbnail Available",
+
+ "thumbnail.project.alt": "Project Logo",
+
+ "thumbnail.project.placeholder": "Project Placeholder Image",
+
+ "thumbnail.orgunit.alt": "OrgUnit Logo",
+
+ "thumbnail.orgunit.placeholder": "OrgUnit Placeholder Image",
+
+ "thumbnail.person.alt": "Profile Picture",
+
+ "thumbnail.person.placeholder": "No Profile Picture Available",
+
+
+
"title": "DSpace",
diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss
index 51195b50a1..657737cc58 100644
--- a/src/styles/_custom_variables.scss
+++ b/src/styles/_custom_variables.scss
@@ -46,6 +46,9 @@
--ds-edit-item-language-field-width: 43px;
--ds-thumbnail-max-width: 175px;
+ --ds-thumbnail-placeholder-background: #{$gray-100};
+ --ds-thumbnail-placeholder-border: 1px solid #{$gray-300};
+ --ds-thumbnail-placeholder-color: #{lighten($gray-800, 7%)};
--ds-dso-selector-list-max-height: 475px;
--ds-dso-selector-current-background-color: #eeeeee;