From d7dc83acb05e7755d4260fc0f3918b7bee7e34a4 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 13 Jan 2020 22:10:24 +0100 Subject: [PATCH] Stock Photo (#463) * Stock Photo * Link to profile. * Styling fix. * Style improvements. --- .../Schemas/StringFieldEditor.cs | 1 + frontend/app/features/content/declarations.ts | 9 +- frontend/app/features/content/module.ts | 10 +- .../shared/field-editor.component.html | 3 + .../shared/stock-photo-editor.component.html | 42 +++++++ .../shared/stock-photo-editor.component.scss | 106 ++++++++++++++++ .../shared/stock-photo-editor.component.ts | 114 ++++++++++++++++++ .../schema/types/string-ui.component.html | 7 ++ .../angular/forms/autocomplete.component.ts | 2 +- frontend/app/shared/internal.ts | 1 + frontend/app/shared/module.ts | 2 + .../app/shared/services/help.service.spec.ts | 2 +- frontend/app/shared/services/schemas.types.ts | 2 +- .../services/stock-photo.service.spec.ts | 79 ++++++++++++ .../shared/services/stock-photo.service.ts | 45 +++++++ .../app/shared/state/contents.forms.spec.ts | 18 +++ frontend/app/shared/state/contents.forms.ts | 30 ++++- 17 files changed, 458 insertions(+), 15 deletions(-) create mode 100644 frontend/app/features/content/shared/stock-photo-editor.component.html create mode 100644 frontend/app/features/content/shared/stock-photo-editor.component.scss create mode 100644 frontend/app/features/content/shared/stock-photo-editor.component.ts create mode 100644 frontend/app/shared/services/stock-photo.service.spec.ts create mode 100644 frontend/app/shared/services/stock-photo.service.ts diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs index 0ceb0a9a4..286a9e689 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs @@ -17,6 +17,7 @@ namespace Squidex.Domain.Apps.Core.Schemas Radio, RichText, Slug, + StockPhoto, TextArea } } diff --git a/frontend/app/features/content/declarations.ts b/frontend/app/features/content/declarations.ts index 60a13a382..ebcfc948f 100644 --- a/frontend/app/features/content/declarations.ts +++ b/frontend/app/features/content/declarations.ts @@ -20,14 +20,15 @@ export * from './shared/assets-editor.component'; export * from './shared/content-list-cell.directive'; export * from './shared/content-list-field.component'; export * from './shared/content-list-header.component'; -export * from './shared/content.component'; +export * from './shared/content-selector-item.component'; export * from './shared/content-status.component'; -export * from './shared/content-value.component'; export * from './shared/content-value-editor.component'; -export * from './shared/content-selector-item.component'; +export * from './shared/content-value.component'; +export * from './shared/content.component'; export * from './shared/contents-selector.component'; export * from './shared/due-time-selector.component'; export * from './shared/field-editor.component'; export * from './shared/preview-button.component'; export * from './shared/reference-item.component'; -export * from './shared/references-editor.component'; \ No newline at end of file +export * from './shared/references-editor.component'; +export * from './shared/stock-photo-editor.component'; \ No newline at end of file diff --git a/frontend/app/features/content/module.ts b/frontend/app/features/content/module.ts index f590107f2..e47af093e 100644 --- a/frontend/app/features/content/module.ts +++ b/frontend/app/features/content/module.ts @@ -46,7 +46,8 @@ import { PreviewButtonComponent, ReferenceItemComponent, ReferencesEditorComponent, - SchemasPageComponent + SchemasPageComponent, + StockPhotoEditorComponent } from './declarations'; const routes: Routes = [ @@ -106,9 +107,9 @@ const routes: Routes = [ @NgModule({ imports: [ + RouterModule.forChild(routes), SqxFrameworkModule, - SqxSharedModule, - RouterModule.forChild(routes) + SqxSharedModule ], declarations: [ ArrayEditorComponent, @@ -137,7 +138,8 @@ const routes: Routes = [ PreviewButtonComponent, ReferenceItemComponent, ReferencesEditorComponent, - SchemasPageComponent + SchemasPageComponent, + StockPhotoEditorComponent ] }) export class SqxFeatureContentModule {} \ No newline at end of file diff --git a/frontend/app/features/content/shared/field-editor.component.html b/frontend/app/features/content/shared/field-editor.component.html index 2c3b729bd..bf2d6b416 100644 --- a/frontend/app/features/content/shared/field-editor.component.html +++ b/frontend/app/features/content/shared/field-editor.component.html @@ -124,6 +124,9 @@ + + + + + + +
+ +
+ + +
+ Nothing selected +
+
+ +
+ + + + + +
+
+ + + +
+
+
+
+ \ No newline at end of file diff --git a/frontend/app/features/content/shared/stock-photo-editor.component.scss b/frontend/app/features/content/shared/stock-photo-editor.component.scss new file mode 100644 index 000000000..2cdc7af75 --- /dev/null +++ b/frontend/app/features/content/shared/stock-photo-editor.component.scss @@ -0,0 +1,106 @@ +@import '_vars'; +@import '_mixins'; + +$color-user-background: rgba(0, 0, 0, .5); +$color-background: #000; + +$height: 300px; + +.col-image { + @include force-width(400px); + + &.expand { + @include force-width(100%); + } + + img { + max-width: 100%; + } +} + +.value { + & { + padding-right: 2.5rem; + } + + &-clear { + @include absolute(0, 0, auto, auto); + } +} + +.preview { + & { + @include force-height($height); + align-items: center; + background: $color-background; + border: 0; + border-radius: .25rem; + color: $color-dark-foreground; + display: flex; + justify-content: center; + margin: 0; + margin-top: .5rem; + } + + &-empty { + padding: 1rem; + } + + img { + max-height: $height; + } +} + +sqx-list-view { + border: 1px solid $color-input; + border-radius: .25rem; + height: $height; + margin-top: .5rem; +} + +.icon { + @include absolute($height * .5, auto, auto, 5px); + color: $color-border; + font-size: 30px; + font-weight: lighter; +} + +.photos { + column-gap: 0; + column-width: 200px; + margin-left: -1rem; + margin-right: -1rem; +} + +.photo { + & { + border: 2px solid $color-border; + border-radius: 0; + display: inline-block; + margin-bottom: .5rem; + margin-right: .5rem; + position: relative; + } + + &:hover { + border-color: $color-theme-blue; + } + + &.selected { + border-color: $color-theme-blue; + } + + &-user { + @include absolute(auto, 0, 0, 0); + background: $color-user-background; + border: 0; + padding: .5rem .75rem; + } + + &-user-link { + @include truncate; + color: $color-dark-foreground; + font-size: 90%; + font-weight: normal; + } +} \ No newline at end of file diff --git a/frontend/app/features/content/shared/stock-photo-editor.component.ts b/frontend/app/features/content/shared/stock-photo-editor.component.ts new file mode 100644 index 000000000..8a7fe090d --- /dev/null +++ b/frontend/app/features/content/shared/stock-photo-editor.component.ts @@ -0,0 +1,114 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; +import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, shareReplay, startWith, switchMap, tap } from 'rxjs/operators'; + +import { + StatefulControlComponent, + StockPhotoDto, + StockPhotoService, + thumbnail, + Types +} from '@app/shared'; + +interface State { + isLoading?: boolean; +} + +export const SQX_STOCK_PHOTO_EDITOR_CONTROL_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StockPhotoEditorComponent), multi: true +}; + +@Component({ + selector: 'sqx-stock-photo-editor', + styleUrls: ['./stock-photo-editor.component.scss'], + templateUrl: './stock-photo-editor.component.html', + providers: [SQX_STOCK_PHOTO_EDITOR_CONTROL_VALUE_ACCESSOR], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class StockPhotoEditorComponent extends StatefulControlComponent implements OnInit { + @Input() + public isCompact = false; + + public valueControl = new FormControl(''); + + public valueThumb = + this.valueControl.valueChanges.pipe( + startWith(this.valueControl.value), + shareReplay(1), + map(value => thumbnail(value, 400) || value)); + + public stockPhotoSearch = new FormControl(''); + + public stockPhotos = + this.stockPhotoSearch.valueChanges.pipe( + startWith(this.stockPhotoSearch.value), + distinctUntilChanged(), + debounceTime(500), + tap(query => { + if (query && query.length > 0) { + this.next({ isLoading: true }); + } + }), + switchMap(query => { + if (query && query.length > 0) { + return this.stockPhotoService.getImages(query); + } else { + return of([]); + } + }), + tap(() => { + this.next({ isLoading: false }); + })); + + constructor(changeDetector: ChangeDetectorRef, + private readonly stockPhotoService: StockPhotoService + ) { + super(changeDetector, {}); + } + + public ngOnInit() { + this.own(this.valueThumb); + + this.own( + this.valueControl.valueChanges + .subscribe(value => { + this.callChange(value); + })); + } + + public writeValue(obj: string) { + if (Types.isString(obj)) { + this.valueControl.setValue(obj, { emitEvent: true }); + } else { + this.valueControl.setValue('', { emitEvent: true }); + } + } + + public selectPhoto(photo: StockPhotoDto) { + if (!this.snapshot.isDisabled) { + this.valueControl.setValue(photo.url); + } + } + + public reset() { + if (!this.snapshot.isDisabled) { + this.valueControl.setValue(''); + } + } + + public isSelected(photo: StockPhotoDto) { + return photo.url === this.valueControl.value; + } + + public trackByPhoto(index: number, photo: StockPhotoDto) { + return photo.thumbUrl; + } +} \ No newline at end of file diff --git a/frontend/app/features/schemas/pages/schema/types/string-ui.component.html b/frontend/app/features/schemas/pages/schema/types/string-ui.component.html index 3cd8cb71e..3b1ad566a 100644 --- a/frontend/app/features/schemas/pages/schema/types/string-ui.component.html +++ b/frontend/app/features/schemas/pages/schema/types/string-ui.component.html @@ -88,6 +88,13 @@ HTML +
diff --git a/frontend/app/framework/angular/forms/autocomplete.component.ts b/frontend/app/framework/angular/forms/autocomplete.component.ts index b532a26b3..f09627086 100644 --- a/frontend/app/framework/angular/forms/autocomplete.component.ts +++ b/frontend/app/framework/angular/forms/autocomplete.component.ts @@ -89,7 +89,7 @@ export class AutocompleteComponent extends StatefulControlComponent !!query && !!this.source), switchMap(query => this.source.find(query)), catchError(() => of([]))) diff --git a/frontend/app/shared/internal.ts b/frontend/app/shared/internal.ts index 152df1578..328ad21a6 100644 --- a/frontend/app/shared/internal.ts +++ b/frontend/app/shared/internal.ts @@ -28,6 +28,7 @@ export * from './services/roles.service'; export * from './services/rules.service'; export * from './services/schemas.service'; export * from './services/schemas.types'; +export * from './services/stock-photo.service'; export * from './services/translations.service'; export * from './services/ui.service'; export * from './services/usages.service'; diff --git a/frontend/app/shared/module.ts b/frontend/app/shared/module.ts index 0c075a477..a917010a1 100644 --- a/frontend/app/shared/module.ts +++ b/frontend/app/shared/module.ts @@ -93,6 +93,7 @@ import { SchemaTagSource, SearchFormComponent, SortingComponent, + StockPhotoService, TableHeaderComponent, TranslationsService, UIService, @@ -253,6 +254,7 @@ export class SqxSharedModule { SchemasService, SchemasState, SchemaTagSource, + StockPhotoService, TranslationsService, UIService, UIState, diff --git a/frontend/app/shared/services/help.service.spec.ts b/frontend/app/shared/services/help.service.spec.ts index 641e886e6..746b5515c 100644 --- a/frontend/app/shared/services/help.service.spec.ts +++ b/frontend/app/shared/services/help.service.spec.ts @@ -10,7 +10,7 @@ import { inject, TestBed } from '@angular/core/testing'; import { HelpService } from '@app/shared/internal'; -describe('ClientsService', () => { +describe('HelpService', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ diff --git a/frontend/app/shared/services/schemas.types.ts b/frontend/app/shared/services/schemas.types.ts index 4f46d5819..22a492863 100644 --- a/frontend/app/shared/services/schemas.types.ts +++ b/frontend/app/shared/services/schemas.types.ts @@ -309,7 +309,7 @@ export class ReferencesFieldPropertiesDto extends FieldPropertiesDto { } } -export type StringEditor = 'Color' | 'Dropdown' | 'Html' | 'Input' | 'Markdown' | 'Radio' | 'RichText' | 'Slug' | 'TextArea'; +export type StringEditor = 'Color' | 'Dropdown' | 'Html' | 'Input' | 'Markdown' | 'Radio' | 'RichText' | 'Slug' | 'StockPhoto' | 'TextArea'; export class StringFieldPropertiesDto extends FieldPropertiesDto { public readonly fieldType = 'String'; diff --git a/frontend/app/shared/services/stock-photo.service.spec.ts b/frontend/app/shared/services/stock-photo.service.spec.ts new file mode 100644 index 000000000..c3facfe10 --- /dev/null +++ b/frontend/app/shared/services/stock-photo.service.spec.ts @@ -0,0 +1,79 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; + +import { StockPhotoDto, StockPhotoService } from '@app/shared/internal'; + +describe('StockPhotoService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + StockPhotoService + ] + }); + }); + + afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { + httpMock.verify(); + })); + + it('should make get request to get stock photos', + inject([StockPhotoService, HttpTestingController], (stockPhotoService: StockPhotoService, httpMock: HttpTestingController) => { + + let images: ReadonlyArray; + + stockPhotoService.getImages('my-query').subscribe(result => { + images = result; + }); + + const req = httpMock.expectOne('https://stockphoto.squidex.io/?query=my-query&pageSize=100'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush([{ + url: 'url1', + thumbUrl: 'thumb1', + user: 'user1', + userProfileUrl: 'user1-url' + }, { + url: 'url2', + thumbUrl: 'thumb2', + user: 'user2', + userProfileUrl: 'user2-url' + }]); + + expect(images!).toEqual([ + new StockPhotoDto('url1', 'thumb1', 'user1', 'user1-url'), + new StockPhotoDto('url2', 'thumb2', 'user2', 'user2-url') + ]); + })); + + it('should return empty stock photos if get request fails', + inject([StockPhotoService, HttpTestingController], (stockPhotoService: StockPhotoService, httpMock: HttpTestingController) => { + + let images: ReadonlyArray; + + stockPhotoService.getImages('my-query').subscribe(result => { + images = result; + }); + + const req = httpMock.expectOne('https://stockphoto.squidex.io/?query=my-query&pageSize=100'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.error({}); + + expect(images!).toEqual([]); + })); +}); \ No newline at end of file diff --git a/frontend/app/shared/services/stock-photo.service.ts b/frontend/app/shared/services/stock-photo.service.ts new file mode 100644 index 000000000..d20fd383f --- /dev/null +++ b/frontend/app/shared/services/stock-photo.service.ts @@ -0,0 +1,45 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +export class StockPhotoDto { + constructor ( + public readonly url: string, + public readonly thumbUrl: string, + public readonly user: string, + public readonly userProfileUrl: string + ) { + } +} + +@Injectable() +export class StockPhotoService { + constructor( + private readonly http: HttpClient + ) { + } + + public getImages(query: string): Observable> { + const url = `https://stockphoto.squidex.io/?query=${query}&pageSize=100`; + + return this.http.get(url).pipe( + map(body => { + return body.map(x => + new StockPhotoDto( + x.url, + x.thumbUrl, + x.user, + x.userProfileUrl + )); + }), + catchError(() => of([]))); + } +} \ No newline at end of file diff --git a/frontend/app/shared/state/contents.forms.spec.ts b/frontend/app/shared/state/contents.forms.spec.ts index 8009daec9..64e555302 100644 --- a/frontend/app/shared/state/contents.forms.spec.ts +++ b/frontend/app/shared/state/contents.forms.spec.ts @@ -417,6 +417,24 @@ describe('StringField', () => { expect(FieldFormatter.format(field, 'hello')).toBe('hello'); }); + it('should format to preview image', () => { + const field2 = createField({ properties: createProperties('String', { editor: 'StockPhoto' }) }); + + expect(FieldFormatter.format(field2, 'https://images.unsplash.com/123?x', true)).toEqual(new HtmlValue('')); + }); + + it('should not format to preview image when html not allowed', () => { + const field2 = createField({ properties: createProperties('String', { editor: 'StockPhoto' }) }); + + expect(FieldFormatter.format(field2, 'https://images.unsplash.com/123?x', false)).toBe('https://images.unsplash.com/123?x'); + }); + + it('should not format to preview image when not unsplash image', () => { + const field2 = createField({ properties: createProperties('String', { editor: 'StockPhoto' }) }); + + expect(FieldFormatter.format(field2, 'https://images.com/123?x', true)).toBe('https://images.com/123?x'); + }); + it('should return default value for default properties', () => { const field2 = createField({ properties: createProperties('String', { defaultValue: 'MyDefault' }) }); diff --git a/frontend/app/shared/state/contents.forms.ts b/frontend/app/shared/state/contents.forms.ts index 9d8dcd16e..8c149f2be 100644 --- a/frontend/app/shared/state/contents.forms.ts +++ b/frontend/app/shared/state/contents.forms.ts @@ -206,10 +206,6 @@ export class FieldFormatter implements FieldPropertiesVisitor { } } - public visitString(_: StringFieldPropertiesDto): any { - return this.value; - } - public visitTags(_: TagsFieldPropertiesDto): string { if (this.value.length) { return this.value.join(', '); @@ -218,11 +214,37 @@ export class FieldFormatter implements FieldPropertiesVisitor { } } + public visitString(properties: StringFieldPropertiesDto): any { + if (properties.editor === 'StockPhoto' && this.allowHtml && this.value) { + const src = thumbnail(this.value, undefined, 50); + + if (src) { + return new HtmlValue(``); + } + } + + return this.value; + } + public visitUI(_: UIFieldPropertiesDto): any { return ''; } } +export function thumbnail(url: string, width?: number, height?: number) { + if (url && url.startsWith('https://images.unsplash.com')) { + if (width) { + return `${url}&q=80&fm=jpg&crop=entropy&cs=tinysrgb&w=${width}&fit=max`; + } + + if (height) { + return `${url}&q=80&fm=jpg&crop=entropy&cs=tinysrgb&h=${height}&fit=max`; + } + } + + return undefined; +} + export class FieldsValidators implements FieldPropertiesVisitor> { private constructor( private readonly isOptional: boolean