First iteration of gifs image selector + card

refs https://github.com/TryGhost/Team/issues/1217

- adds `tenor` service that acts as a coordinator for the Tenor API similar to the `unsplash` service
- adds `<GhTenor>` component that renders an image search and select modal using the `tenor` service
- swapped the gifs card over to use the `tenor` image selector so it opens the tenor modal instead of the unsplash modal
This commit is contained in:
Kevin Ansfield 2021-11-12 16:10:19 +00:00
parent 0bc5d322d4
commit 6853b964f8
8 changed files with 559 additions and 2 deletions

View File

@ -0,0 +1,95 @@
{{!-- TODO: switch to {{css-transition}}? --}}
<LiquidWormhole @class="unsplash">
{{!-- TODO: why does this modal background not cover the PSM without style override? --}}
<div class="fullscreen-modal-background" style="z-index: 999" {{on "click" @close}}></div>
<div class="absolute top-8 right-8 bottom-8 left-8 br4 overflow-hidden bg-white z-9999" {{on-key "Escape" this.handleEscape}} {{did-insert this.setInitialSearch}} data-tenor>
{{!-- close button --}}
<button type="button" class="absolute top-6 right-6" {{on "click" @close}}>
{{svg-jar "close" class="w4 stroke-midlightgrey-l2"}}
</button>
<div class="flex flex-column h-100">
{{!-- static header --}}
<header class="flex-shrink-0 flex flex-row-l flex-column justify-between pt6 pr8 pb6 pl8 pt10-l pr20-l pb10-l pl20-l items-center">
<h1 class="flex items-center darkgrey-d2 w-100 nudge-top--4">
<a class="dib w-30 mr2" href="https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">
{{svg-jar "powered-by-tenor"}}
</a>
</h1>
<span class="gh-input-icon mw88-l flex-auto w-100 mt3 mt0-l">
{{svg-jar "search"}}
<GhTextInput
@class="gh-unsplash-search"
@name="searchKeyword"
@placeholder="Search Tenor"
@tabindex="1"
@shouldFocus={{true}}
@autocorrect="off"
@value={{readonly this.tenor.searchTerm}}
@input={{this.search}}
/>
</span>
</header>
{{!-- content container --}}
<div class="relative h-100 overflow-hidden">
{{!-- scrollable image container --}}
<div class="overflow-auto h-100 w-100 pr8 pl8 pr20-l pl20-l">
{{#if this.tenor.gifs}}
<section class="gh-unsplash-grid">
{{#each this.tenor.columns as |gifs|}}
<div class="gh-unsplash-grid-column">
{{#each gifs as |gif|}}
<GhTenor::Gif
@gif={{gif}}
@zoom={{fn this.zoom gif}}
@select={{fn this.select gif}} />
{{/each}}
</div>
{{/each}}
</section>
{{else if (and this.tenor.searchTerm (not this.tenor.error this.tenor.isLoading))}}
<section class="gh-unsplash-error h-100 flex items-center justify-center pb30">
<div>
<img class="gh-unsplash-error-404" src="assets/img/unsplash-404.png" alt="No photos found" />
<h4>No gifs found for '{{this.tenor.searchTerm}}'</h4>
</div>
</section>
{{/if}}
{{#if this.tenor.error}}
<section class="gh-unsplash-error h-100 flex items-center justify-center pb30">
<div>
<img class="gh-unsplash-error-404" src="assets/img/unsplash-404.png" alt="Network error" />
<h4>{{this.tenor.error}} (<a href="#" {{on "click" this.retry}}>retry</a>)</h4>
</div>
</section>
{{/if}}
{{#if this.tenor.isLoading}}
<div class="gh-unsplash-loading h-100 flex items-center justify-center pb30">
<div class="gh-loading-spinner"></div>
</div>
{{/if}}
{{#unless this.tenor.isLoading}}
<GhScrollTrigger
@enter={{this.tenor.loadNextPage}}
@triggerOffset={{1000}} />
{{/unless}}
</div>
{{!-- zoomed image overlay --}}
{{#if this.zoomedGif}}
<div class="absolute flex justify-center top-0 right-0 bottom-0 left-0 pr20 pb10 pl20 bg-white overflow-hidden" {{action "closeZoom"}}>
<GhTenor::Gif
@gif={{this.zoomedGif}}
@zoomed={{true}}
@zoom={{this.closeZoom}}
@select={{fn this.select this.zoomedGif}} />
</div>
{{/if}}
</div>
</div>
</div>
</LiquidWormhole>

View File

@ -0,0 +1,63 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class GhTenorComponent extends Component {
@service tenor;
@tracked zoomedGif;
@action
search(event) {
const term = event.target.value;
this.tenor.updateSearch(term);
this.closeZoom();
}
@action
setInitialSearch() {
if (this.args.searchTerm !== this.tenor.searchTerm) {
this.tenor.updateSearch(this.args.searchTerm);
}
}
@action
zoom(gif, event) {
event?.preventDefault();
this.zoomedGif = gif;
}
@action
closeZoom(event) {
event?.preventDefault();
this.zoomedGif = null;
}
@action
select(gif, event) {
event?.preventDefault();
event?.stopPropagation();
const media = gif.media[0].gif;
const selectParams = {
src: media.url,
width: media.dims[0],
height: media.dims[1],
caption: '(Via <a href="https://tenor.com">Tenor</a>)'
};
this.args.select(selectParams);
this.args.close();
}
@action
handleEscape() {
if (this.zoomedGif) {
this.zoomedGif = null;
} else {
this.args.close();
}
}
}

View File

@ -0,0 +1,23 @@
<a class="gh-unsplash-photo" href="#" {{on "click" @zoom}} data-tenor-zoomed-gif={{if @zoomed @gif.id}} data-test-tenor-gif={{@gif.id}} style={{this.style}} {{did-insert this.didInsert}}>
<div class="gh-unsplash-photo-container" style={{this.containerStyle}} data-test-tenor-gif-container>
<img src={{this.imageUrl}} alt={{@gif.description}} width={{this.width}} height={{this.height}} data-test-tenor-gif-image />
<div class="gh-unsplash-photo-overlay">
<div class="gh-unsplash-photo-header">
{{!-- <a class="gh-unsplash-button-likes gh-unsplash-button" href="{{@gif.links.html}}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">{{svg-jar "unsplash-heart"}}{{@gif.likes}}</a>
<a class="gh-unsplash-button-download gh-unsplash-button" href="{{@gif.links.download}}/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit&force=true">{{svg-jar "download"}}</a> --}}
</div>
<div class="gh-unsplash-photo-footer">
<div class="gh-unsplash-photo-author">
{{!-- <a class="gh-unsplash-photo-author-img" href="{{@gif.user.links.html}}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">
<img src="{{@gif.user.profile_image.medium}}" />
</a>
<a class="gh-unsplash-photo-author-name" href="{{@gif.user.links.html}}?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit" target="_blank">
{{@gif.user.name}}
</a> --}}
</div>
<button class="gh-unsplash-button" {{on "click" @select}}>Insert gif</button>
</div>
</div>
</div>
</a>

View File

@ -0,0 +1,116 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {htmlSafe} from '@ember/template';
import {run} from '@ember/runloop';
export default class GhTenorGifComponent extends Component {
get media() {
if (this.args.zoomed) {
return this.args.gif.media[0].gif;
} else {
return this.args.gif.media[0].tinygif;
}
}
get imageUrl() {
return this.media.url;
}
get width() {
return this.media.dims[0];
}
get height() {
return this.media.dims[1];
}
get style() {
return htmlSafe(this.args.zoomed ? 'width: auto; margin: 0' : '');
}
get containerStyle() {
if (!this.args.gif) {
return htmlSafe('');
}
const styles = [];
const ratio = this.args.gif.ratio;
const zoomed = this.args.zoomed;
if (zoomed) {
styles.push(`cursor: zoom-out`);
} else {
styles.push(`padding-bottom: ${ratio * 100}%`);
}
return htmlSafe(styles.join('; '));
}
willDestroy() {
super.willDestroy(...arguments);
this._teardownResizeHandler();
}
@action
didInsert() {
this._hasRendered = true;
if (this.args.zoomed) {
this._setZoomedSize();
this._setupResizeHandler();
}
}
// adjust dimensions so that the full gif is visible on-screen no matter it's ratio
_setZoomedSize() {
if (!this._hasRendered) {
return;
}
const a = document.querySelector(`[data-tenor-zoomed-gif="${this.args.gif.id}"]`);
a.style.width = '100%';
a.style.height = '100%';
const offsets = a.getBoundingClientRect();
const ratio = this.args.gif.ratio;
const maxHeight = {
width: offsets.height / ratio,
height: offsets.height
};
const maxWidth = {
width: offsets.width,
height: offsets.width * ratio
};
let usableSize;
if (ratio <= 1) {
usableSize = maxWidth.height > offsets.height ? maxHeight : maxWidth;
} else {
usableSize = maxHeight.width > offsets.width ? maxWidth : maxHeight;
}
a.style.width = `${usableSize.width}px`;
a.style.height = `${usableSize.height}px`;
}
_setupResizeHandler() {
if (this._resizeHandler) {
return;
}
this._resizeHandler = run.bind(this, this._handleResize);
window.addEventListener('resize', this._resizeHandler);
}
_teardownResizeHandler() {
window.removeEventListener('resize', this._resizeHandler);
}
_handleResize() {
this._throttleResize = run.throttle(this, this._setZoomedSize, 100);
}
}

View File

@ -0,0 +1,224 @@
import Service from '@ember/service';
import fetch from 'fetch';
import {TrackedArray} from 'tracked-built-ins';
import {action} from '@ember/object';
import {isEmpty} from '@ember/utils';
import {inject as service} from '@ember/service';
import {task, taskGroup} from 'ember-concurrency-decorators';
import {timeout} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
const API_URL = 'https://g1.tenor.com';
const API_VERSION = 'v1';
const DEBOUNCE_MS = 600;
export default class TenorService extends Service {
@service config;
@tracked columnCount = 4;
@tracked columns = null;
@tracked error = null;
@tracked gifs = new TrackedArray([]);
@tracked searchTerm = '';
@tracked loadedType = 'trending';
_columnHeights = [];
_nextPos = null;
get apiKey() {
return this.config.get('tenorApiKey');
}
get isLoading() {
return this.searchTask.isRunning || this.loadingTasks.isRunning;
}
constructor() {
super(...arguments);
this._resetColumns();
}
@action
updateSearch(term) {
if (term === this.searchTerm) {
return;
}
this.searchTerm = term;
this.reset();
if (term) {
return this.searchTask.perform(term);
} else {
return this.loadTrendingTask.perform();
}
}
@action
loadNextPage() {
// protect against scroll trigger firing when the gifs are reset
if (this.searchTask.isRunning) {
return;
}
if (isEmpty(this.gifs)) {
return this.loadTrendingTask.perform();
}
if (this._nextPos !== null) {
this.loadNextPageTask.perform();
}
}
@task({restartable: true})
*searchTask(term) {
yield timeout(DEBOUNCE_MS);
this.loadedType = 'search';
yield this._makeRequest(this.loadedType, {params: {
q: term,
media_filter: 'minimal'
}});
}
@taskGroup loadingTasks;
@task({group: 'loadingTasks'})
*loadTrendingTask() {
this.loadedType = 'trending';
yield this._makeRequest(this.loadedType, {params: {
media_filter: 'minimal'
}});
}
@task({group: 'loadingTasks'})
*loadNextPageTask() {
const params = {
pos: this._nextPos,
media_filter: 'minimal'
};
if (this.loadedType === 'search') {
params.q = this.searchTerm;
}
yield this._makeRequest(this.loadedType, {params});
}
@task({group: 'loadingTasks'})
*retryLastRequestTask() {
if (this._lastRequestArgs) {
yield this._makeRequest(...this._lastRequestArgs);
}
}
reset() {
this.gifs = new TrackedArray([]);
this._nextPos = null;
this._resetColumns();
}
async _makeRequest(path, options) {
const versionedPath = `${API_VERSION}/${path}`.replace(/\/+/, '/');
const url = new URL(versionedPath, API_URL);
const params = new URLSearchParams(options.params);
params.append('key', this.apiKey);
url.search = params.toString();
// store the url so it can be retried if needed
this._lastRequestArgs = arguments;
this.error = '';
return fetch(url)
.then(response => this._checkStatus(response))
.then(response => response.json())
.then(response => this._extractPagination(response))
.then(response => this._addGifsFromResponse(response))
.catch((e) => {
// if the error text isn't already set then we've get a connection error from `fetch`
if (!options.ignoreErrors && !this.error) {
this.error = 'Uh-oh! Trouble reaching the Tenor API, please check your connection';
}
console.error(e); // eslint-disable-line
});
}
async _checkStatus(response) {
// successful request
if (response.status >= 200 && response.status < 300) {
return response;
}
let responseText;
if (response.headers.map['content-type'] === 'application/json') {
responseText = await response.json().then(json => json.errors[0]);
} else if (response.headers.map['content-type'] === 'text/xml') {
responseText = await response.text();
}
this.error = responseText;
const error = new Error(responseText);
error.response = response;
throw error;
}
async _extractPagination(response) {
this._nextPos = response.next;
return response;
}
async _addGifsFromResponse(response) {
const gifs = response.results;
gifs.forEach(gif => this._addGif(gif));
return response;
}
_addGif(gif) {
// re-calculate ratio for later use
const [width, height] = gif.media[0].tinygif.dims;
gif.ratio = height / width;
// add to general gifs list
this.gifs.push(gif);
// add to least populated column
this._addGifToColumns(gif);
}
_addGifToColumns(gif) {
const min = Math.min(...this._columnHeights);
const columnIndex = this._columnHeights.indexOf(min);
// use a fixed width when calculating height to compensate for different overall sizes
this._columnHeights[columnIndex] += 300 * gif.ratio;
this.columns[columnIndex].push(gif);
}
_resetColumns() {
let columns = new TrackedArray([]);
let _columnHeights = [];
// pre-fill column arrays based on columnCount
for (let i = 0; i < this.columnCount; i += 1) {
columns[i] = new TrackedArray([]);
_columnHeights[i] = 0;
}
this.columns = columns;
this._columnHeights = _columnHeights;
if (!isEmpty(this.gifs)) {
this.gifs.forEach((gif) => {
this._addGifToColumns(gif);
});
}
}
}

View File

@ -46,7 +46,8 @@ export default Component.extend({
imageSelector: computed('payload.imageSelector', function () {
let selector = this.payload.imageSelector;
let imageSelectors = {
unsplash: 'gh-unsplash'
unsplash: 'gh-unsplash',
tenor: 'gh-tenor'
};
return imageSelectors[selector];

View File

@ -221,7 +221,7 @@ export const CARD_MENU = [
replaceArg: 'image',
params: ['searchTerm'],
payload: {
imageSelector: 'unsplash'
imageSelector: 'tenor'
},
feature: 'gifsCard'
},

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB