mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
17 changed files with 458 additions and 15 deletions
@ -0,0 +1,42 @@ |
|||||
|
<div class="row no-gutters"> |
||||
|
<div class="col-auto col-image" [class.expand]="isCompact"> |
||||
|
<input class="form-control value" [formControl]="valueControl" readonly /> |
||||
|
|
||||
|
<button type="button" class="btn btn-text-secondary value-clear" (click)="reset()"> |
||||
|
<i class="icon-close"></i> |
||||
|
</button> |
||||
|
|
||||
|
<div *ngIf="valueThumb | async; let thumbUrl; else noThumb" class="preview"> |
||||
|
<img [src]="thumbUrl" /> |
||||
|
</div> |
||||
|
|
||||
|
<ng-template #noThumb> |
||||
|
<div class="preview preview-empty"> |
||||
|
Nothing selected |
||||
|
</div> |
||||
|
</ng-template> |
||||
|
</div> |
||||
|
<div class="col pl-4" *ngIf="!isCompact"> |
||||
|
<i class="icon-angle-left icon"></i> |
||||
|
|
||||
|
<input class="form-control" [formControl]="stockPhotoSearch" placeholder="Search for Photos by Unsplash" /> |
||||
|
|
||||
|
<sqx-list-view [isLoading]="snapshot.isLoading" table="true"> |
||||
|
<div content> |
||||
|
<div class="photos"> |
||||
|
<ng-container *ngIf="stockPhotos | async; let photos"> |
||||
|
<div *ngFor="let photo of photos" class="photo" [class.selected]="isSelected(photo)" (click)="selectPhoto(photo)"> |
||||
|
<img [src]="photo.thumbUrl" /> |
||||
|
|
||||
|
<div class="photo-user"> |
||||
|
<a class="photo-user-link" [href]="photo.userProfileUrl" sqxExternalLink sqxStopClick> |
||||
|
{{photo.user}} |
||||
|
</a> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
</div> |
||||
|
</div> |
||||
|
</sqx-list-view> |
||||
|
</div> |
||||
|
</div> |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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<State, string> 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; |
||||
|
} |
||||
|
} |
||||
@ -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<StockPhotoDto>; |
||||
|
|
||||
|
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<StockPhotoDto>; |
||||
|
|
||||
|
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(<any>{}); |
||||
|
|
||||
|
expect(images!).toEqual([]); |
||||
|
})); |
||||
|
}); |
||||
@ -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<ReadonlyArray<StockPhotoDto>> { |
||||
|
const url = `https://stockphoto.squidex.io/?query=${query}&pageSize=100`; |
||||
|
|
||||
|
return this.http.get<any[]>(url).pipe( |
||||
|
map(body => { |
||||
|
return body.map(x => |
||||
|
new StockPhotoDto( |
||||
|
x.url, |
||||
|
x.thumbUrl, |
||||
|
x.user, |
||||
|
x.userProfileUrl |
||||
|
)); |
||||
|
}), |
||||
|
catchError(() => of([]))); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue