mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 06:35:49 +03:00
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:
parent
0bc5d322d4
commit
6853b964f8
95
ghost/admin/app/components/gh-tenor.hbs
Normal file
95
ghost/admin/app/components/gh-tenor.hbs
Normal 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>
|
63
ghost/admin/app/components/gh-tenor.js
Normal file
63
ghost/admin/app/components/gh-tenor.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
23
ghost/admin/app/components/gh-tenor/gif.hbs
Normal file
23
ghost/admin/app/components/gh-tenor/gif.hbs
Normal 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>
|
116
ghost/admin/app/components/gh-tenor/gif.js
Normal file
116
ghost/admin/app/components/gh-tenor/gif.js
Normal 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);
|
||||
}
|
||||
}
|
224
ghost/admin/app/services/tenor.js
Normal file
224
ghost/admin/app/services/tenor.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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];
|
||||
|
@ -221,7 +221,7 @@ export const CARD_MENU = [
|
||||
replaceArg: 'image',
|
||||
params: ['searchTerm'],
|
||||
payload: {
|
||||
imageSelector: 'unsplash'
|
||||
imageSelector: 'tenor'
|
||||
},
|
||||
feature: 'gifsCard'
|
||||
},
|
||||
|
35
ghost/admin/public/assets/icons/powered-by-tenor.svg
Normal file
35
ghost/admin/public/assets/icons/powered-by-tenor.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 17 KiB |
Loading…
Reference in New Issue
Block a user