mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 06:35:49 +03:00
Added Ghost Explore in Ghost as iframe app behind feature flag (#15495)
no issue - Added Ghost Explore screen behind alpha flag - Moved existing /explore route to /explore/connect which we'll redirect to for outside requests - Added iframe communication with Ghost Explore App
This commit is contained in:
parent
7e3b41f643
commit
c4188c1a9e
@ -1,14 +1,14 @@
|
||||
<section class="gh-dashboard-section gh-dashboard-explore-feed" {{did-insert this.load}}>
|
||||
<article class="gh-dashboard-box">
|
||||
<div class="gh-dashboard-resource-title">
|
||||
<h4>Featured publications
|
||||
<h4>Featured publications
|
||||
{{#if this.meta.category_url}}
|
||||
in <a class="gh-dasboard-explore-title-link" href={{this.meta.category_url}} target="_blank" rel="noopener noreferrer">{{this.meta.category}}</a>
|
||||
{{/if}}
|
||||
</h4>
|
||||
<div>
|
||||
<a href="https://ghost.org/explore/" target="_blank" rel="noopener noreferrer">Browse all</a>
|
||||
<LinkTo @route="explore" class="gh-dashboard-explore-add">Add your site to Explore</LinkTo>
|
||||
<LinkTo @route="explore.connect" class="gh-dashboard-explore-add">Add your site to Explore</LinkTo>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-dashboard-resource-body">
|
||||
|
1
ghost/admin/app/components/gh-explore-iframe.hbs
Normal file
1
ghost/admin/app/components/gh-explore-iframe.hbs
Normal file
@ -0,0 +1 @@
|
||||
<iframe id="explore-frame" class="explore-frame" frameborder="0" title="Explore" ...attributes {{did-insert this.setup}}></iframe>
|
50
ghost/admin/app/components/gh-explore-iframe.js
Normal file
50
ghost/admin/app/components/gh-explore-iframe.js
Normal file
@ -0,0 +1,50 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class GhExploreIframe extends Component {
|
||||
@service explore;
|
||||
@service router;
|
||||
@service feature;
|
||||
|
||||
@action
|
||||
setup() {
|
||||
this.explore.getExploreIframe().src = this.explore.getIframeURL();
|
||||
|
||||
window.addEventListener('message', async (event) => {
|
||||
if (event?.data) {
|
||||
if (event.data?.request === 'apiUrl') {
|
||||
this._handleUrlRequest();
|
||||
}
|
||||
|
||||
if (event.data?.route) {
|
||||
this._handleRouteUpdate(event.data);
|
||||
}
|
||||
|
||||
if (event.data?.siteData) {
|
||||
this._handleSiteDataUpdate(event.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The iframe can send route updates to navigate to within Admin, as some routes
|
||||
// have to be rendered within the iframe and others require to break out of it.
|
||||
_handleRouteUpdate(data) {
|
||||
const route = data.route;
|
||||
this.explore.isIframeTransition = route?.includes('/explore');
|
||||
this.explore.toggleExploreWindow(this.explore.isIframeTransition);
|
||||
this.router.transitionTo(route);
|
||||
}
|
||||
|
||||
_handleUrlRequest() {
|
||||
this.explore.getExploreIframe().contentWindow.postMessage({
|
||||
request: 'apiUrl',
|
||||
response: {apiUrl: this.explore.apiUrl, darkMode: this.feature.nightShift}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
_handleSiteDataUpdate(data) {
|
||||
this.explore.siteData = data.siteData;
|
||||
}
|
||||
}
|
5
ghost/admin/app/components/gh-explore-modal.hbs
Normal file
5
ghost/admin/app/components/gh-explore-modal.hbs
Normal file
@ -0,0 +1,5 @@
|
||||
<div class="{{this.visibilityClass}}">
|
||||
<div class="gh-explore-container">
|
||||
<GhExploreIframe></GhExploreIframe>
|
||||
</div>
|
||||
</div>
|
10
ghost/admin/app/components/gh-explore-modal.js
Normal file
10
ghost/admin/app/components/gh-explore-modal.js
Normal file
@ -0,0 +1,10 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class GhExploreModal extends Component {
|
||||
@service explore;
|
||||
|
||||
get visibilityClass() {
|
||||
return this.explore.exploreWindowOpen ? 'gh-explore' : 'gh-explore closed';
|
||||
}
|
||||
}
|
@ -21,6 +21,11 @@
|
||||
<li class="relative">
|
||||
<LinkTo @route="dashboard" @alt="Dashboard" title="Dashboard" data-test-nav="dashboard">{{svg-jar "house"}} Dashboard</LinkTo>
|
||||
</li>
|
||||
{{#if (feature "exploreApp")}}
|
||||
<li class="relative">
|
||||
<LinkTo @route="explore" title="Ghost Explore" data-test-nav="explore">{{svg-jar "globe"}} Explore</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<li class="relative">
|
||||
<span {{action "transitionToOrRefreshSite" on="click"}}>
|
||||
|
@ -8,6 +8,7 @@ import Controller from '@ember/controller';
|
||||
@classic
|
||||
export default class ApplicationController extends Controller {
|
||||
@service billing;
|
||||
@service explore;
|
||||
@service config;
|
||||
@service dropdown;
|
||||
@service feature;
|
||||
|
@ -3,16 +3,8 @@ import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class ExploreController extends Controller {
|
||||
@service ghostPaths;
|
||||
|
||||
get apiUrl() {
|
||||
const origin = new URL(window.location.origin);
|
||||
const subdir = this.ghostPaths.subdir;
|
||||
// We want the API URL without protocol
|
||||
let url = this.ghostPaths.url.join(origin.host, subdir);
|
||||
|
||||
return url.replace(/\/$/, '');
|
||||
}
|
||||
@service explore;
|
||||
@service router;
|
||||
|
||||
get exploreCredentials() {
|
||||
const explore = this.model.findBy('slug', 'ghost-explore');
|
||||
@ -21,20 +13,44 @@ export default class ExploreController extends Controller {
|
||||
return adminKey.secret;
|
||||
}
|
||||
|
||||
get visibilityClass() {
|
||||
return this.explore.isIframeTransition ? 'explore iframe-explore-container' : ' explore fullscreen-explore-container';
|
||||
}
|
||||
|
||||
@action
|
||||
closeConnect() {
|
||||
if (this.explore.isIframeTransition) {
|
||||
this.router.transitionTo('/explore');
|
||||
} else {
|
||||
this.router.transitionTo('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
submitExploreSite() {
|
||||
const token = this.exploreCredentials;
|
||||
const apiUrl = this.apiUrl;
|
||||
const apiUrl = this.explore.apiUrl;
|
||||
|
||||
// Ghost Explore URL to submit a new site
|
||||
const destination = new URL('https://ghost.org/explore/submit');
|
||||
const query = new URLSearchParams();
|
||||
|
||||
query.append('token', token);
|
||||
query.append('url', apiUrl);
|
||||
|
||||
destination.search = query;
|
||||
if (this.explore.isIframeTransition) {
|
||||
this.explore.sendRouteUpdate({path: this.explore.submitRoute, queryParams: query.toString()});
|
||||
|
||||
window.location = destination.toString();
|
||||
// Set a short timeout to give Explore enough time to navigate
|
||||
// to the submit page and fetch the required site data
|
||||
setTimeout(() => {
|
||||
this.explore.toggleExploreWindow(true);
|
||||
this.router.transitionTo('explore');
|
||||
}, 500);
|
||||
} else {
|
||||
// Ghost Explore URL to submit a new site
|
||||
const destination = new URL(`${this.explore.exploreUrl}${this.explore.submitRoute}`);
|
||||
destination.search = query;
|
||||
|
||||
window.location = destination.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -74,7 +74,17 @@ Router.map(function () {
|
||||
this.route('user', {path: ':user_slug'});
|
||||
});
|
||||
|
||||
this.route('explore');
|
||||
this.route('explore', function () {
|
||||
// actual Ember route, not rendered in iframe
|
||||
this.route('connect');
|
||||
// iframe sub pages, used for categories
|
||||
this.route('explore-sub', {path: '/*sub'}, function () {
|
||||
// needed to allow search to work, as it uses URL
|
||||
// params for search queries. They don't need to
|
||||
// be visible, but may not be cut off.
|
||||
this.route('explore-query', {path: '/*query'});
|
||||
});
|
||||
});
|
||||
|
||||
this.route('settings.integrations', {path: '/settings/integrations'}, function () {
|
||||
this.route('new');
|
||||
|
@ -1,10 +0,0 @@
|
||||
import AdminRoute from 'ghost-admin/routes/admin';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class ExploreRoute extends AdminRoute {
|
||||
@service store;
|
||||
|
||||
model() {
|
||||
return this.store.findAll('integration');
|
||||
}
|
||||
}
|
10
ghost/admin/app/routes/explore/connect.js
Normal file
10
ghost/admin/app/routes/explore/connect.js
Normal file
@ -0,0 +1,10 @@
|
||||
import ExploreRoute from './index';
|
||||
|
||||
export default class ExploreConnectRoute extends ExploreRoute {
|
||||
controllerName = 'explore';
|
||||
|
||||
// Ensure to always close the iframe, as we're now on an Ember route
|
||||
beforeModel() {
|
||||
this.explore.toggleExploreWindow(false);
|
||||
}
|
||||
}
|
5
ghost/admin/app/routes/explore/explore-sub.js
Normal file
5
ghost/admin/app/routes/explore/explore-sub.js
Normal file
@ -0,0 +1,5 @@
|
||||
import ExploreRoute from './index';
|
||||
|
||||
export default class ExploreSubRoute extends ExploreRoute {
|
||||
controllerName = 'explore';
|
||||
}
|
62
ghost/admin/app/routes/explore/index.js
Normal file
62
ghost/admin/app/routes/explore/index.js
Normal file
@ -0,0 +1,62 @@
|
||||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class ExploreRoute extends AuthenticatedRoute {
|
||||
@service explore;
|
||||
@service store;
|
||||
@service router;
|
||||
@service feature;
|
||||
|
||||
beforeModel(transition) {
|
||||
super.beforeModel(...arguments);
|
||||
|
||||
// Usage of query param to ensure that sites can be submitted across
|
||||
// older versions of Ghost where the `connect` part lives in the
|
||||
// explore route directly. By using the query param, we avoid causing
|
||||
// a 404 and handle the redirect here.
|
||||
if (transition.to?.queryParams?.new === 'true' || !this.feature.exploreApp) {
|
||||
this.explore.isIframeTransition = false;
|
||||
return this.router.transitionTo('explore.connect');
|
||||
}
|
||||
|
||||
// Ensure the explore window is set to open
|
||||
if (this.feature.get('exploreApp') && transition.to?.localName === 'index' && !this.explore.exploreWindowOpen) {
|
||||
this.explore.openExploreWindow(this.router.currentURL);
|
||||
}
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.findAll('integration');
|
||||
}
|
||||
|
||||
@action
|
||||
willTransition(transition) {
|
||||
let isExploreTransition = false;
|
||||
|
||||
if (transition) {
|
||||
let destinationUrl = (typeof transition.to === 'string')
|
||||
? transition.to
|
||||
: (transition.intent
|
||||
? transition.intent.url
|
||||
: '');
|
||||
|
||||
if (destinationUrl?.includes('/explore')) {
|
||||
isExploreTransition = true;
|
||||
|
||||
// Send the updated route to the iframe
|
||||
if (transition?.to?.params?.sub) {
|
||||
this.explore.sendRouteUpdate({path: transition.to.params.sub});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.explore.toggleExploreWindow(isExploreTransition);
|
||||
}
|
||||
|
||||
buildRouteInfoMetadata() {
|
||||
return {
|
||||
titleToken: 'Explore'
|
||||
};
|
||||
}
|
||||
}
|
116
ghost/admin/app/services/explore.js
Normal file
116
ghost/admin/app/services/explore.js
Normal file
@ -0,0 +1,116 @@
|
||||
import Service, {inject as service} from '@ember/service';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default class ExploreService extends Service {
|
||||
@service router;
|
||||
@service feature;
|
||||
@service ghostPaths;
|
||||
|
||||
// TODO: make this a config value
|
||||
exploreUrl = 'https://ghost.org/explore/';
|
||||
exploreRouteRoot = '#/explore';
|
||||
submitRoute = 'submit';
|
||||
|
||||
@tracked exploreWindowOpen = false;
|
||||
@tracked siteData = null;
|
||||
@tracked previousRoute = null;
|
||||
@tracked isIframeTransition = false;
|
||||
|
||||
get apiUrl() {
|
||||
const origin = new URL(window.location.origin);
|
||||
const subdir = this.ghostPaths.subdir;
|
||||
// We want the API URL without protocol
|
||||
let url = this.ghostPaths.url.join(origin.host, subdir);
|
||||
|
||||
return url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
if (this.exploreUrl) {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event && event.data && event.data.route) {
|
||||
this.handleRouteChangeInIframe(event.data.route);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleRouteChangeInIframe(destinationRoute) {
|
||||
if (this.exploreWindowOpen) {
|
||||
let exploreRoute = this.exploreRouteRoot;
|
||||
|
||||
if (destinationRoute.match(/^\/explore(\/.*)?/)) {
|
||||
destinationRoute = destinationRoute.replace(/\/explore/, '');
|
||||
}
|
||||
|
||||
if (destinationRoute !== '/') {
|
||||
exploreRoute += destinationRoute;
|
||||
}
|
||||
|
||||
if (window.location.hash !== exploreRoute) {
|
||||
window.history.replaceState(window.history.state, '', exploreRoute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getIframeURL() {
|
||||
let url = this.exploreUrl;
|
||||
|
||||
if (window.location.hash && window.location.hash.includes(this.exploreRouteRoot)) {
|
||||
let destinationRoute = window.location.hash.replace(this.exploreRouteRoot, '');
|
||||
|
||||
// Connect is an Ember route, do not use it as iframe src
|
||||
if (destinationRoute && !destinationRoute.includes('connect')) {
|
||||
url += destinationRoute.replace(/^\//, '');
|
||||
}
|
||||
}
|
||||
|
||||
return url += '/';
|
||||
}
|
||||
|
||||
// Sends a route update to a child route in the BMA, because we can't control
|
||||
// navigating to it otherwise
|
||||
sendRouteUpdate(route) {
|
||||
this.getExploreIframe().contentWindow.postMessage({
|
||||
query: 'routeUpdate',
|
||||
response: route
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// Controls explore window modal visibility and sync of the URL visible in browser
|
||||
// and the URL opened on the iframe. It is responsible to non user triggered iframe opening,
|
||||
// for example: by entering "/explore" route in the URL or using history navigation (back and forward)
|
||||
toggleExploreWindow(value) {
|
||||
if (this.exploreWindowOpen && value) {
|
||||
// don't attempt to open again
|
||||
return;
|
||||
}
|
||||
this.exploreWindowOpen = value;
|
||||
}
|
||||
|
||||
// Controls navigation to explore window modal which is triggered from the application UI.
|
||||
// For example: pressing "View explore" link in navigation menu. It's main side effect is
|
||||
// remembering the route from which the action has been triggered - "previousRoute" so it
|
||||
// could be reused when closing the explore window
|
||||
openExploreWindow(currentRoute, childRoute) {
|
||||
if (this.exploreWindowOpen) {
|
||||
// don't attempt to open again
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousRoute = currentRoute;
|
||||
|
||||
// Ensures correct "getIframeURL" calculation when syncing iframe location
|
||||
// in toggleExploreWindow
|
||||
window.location.hash = childRoute || '/explore';
|
||||
|
||||
this.router.transitionTo(childRoute || '/explore');
|
||||
this.toggleExploreWindow(true);
|
||||
}
|
||||
|
||||
getExploreIframe() {
|
||||
return document.getElementById('explore-frame');
|
||||
}
|
||||
}
|
@ -65,6 +65,7 @@ export default class FeatureService extends Service {
|
||||
@feature('emailAlerts') emailAlerts;
|
||||
@feature('sourceAttribution') sourceAttribution;
|
||||
@feature('lexicalEditor') lexicalEditor;
|
||||
@feature('exploreApp') exploreApp;
|
||||
|
||||
_user = null;
|
||||
|
||||
|
@ -1,3 +1,77 @@
|
||||
.gh-explore {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 9999;
|
||||
background: var(--main-bg-color);
|
||||
}
|
||||
|
||||
.gh-explore-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gh-explore.closed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gh-explore .close {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
right: 19px;
|
||||
z-index: 9999;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.gh-explore .explore-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.gh-explore-close {
|
||||
width: calc(50vw - 200px)
|
||||
}
|
||||
|
||||
.gh-explore-close button {
|
||||
stroke: var(--midgrey);
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease-in-out;
|
||||
top: 25px;
|
||||
}
|
||||
|
||||
/* Connect */
|
||||
.explore {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.iframe-explore-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
transform: translate3d(0, 0, 0);
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, var(--white) 0%, #E1E1E1 100%);
|
||||
}
|
||||
|
||||
.fullscreen-explore-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
@ -14,6 +14,9 @@
|
||||
{{#if this.showBilling}}
|
||||
<GhBillingModal @billingWindowOpen={{this.billing.billingWindowOpen}} />
|
||||
{{/if}}
|
||||
{{#if (feature "exploreApp")}}
|
||||
<GhExploreModal />
|
||||
{{/if}}
|
||||
|
||||
</main>
|
||||
|
||||
|
@ -1,8 +1,8 @@
|
||||
<div class="explore fullscreen-explore-container">
|
||||
<div class="{{this.visibilityClass}}">
|
||||
<div class="explore-close">
|
||||
<LinkTo data-test-button="close-explore" @route="dashboard">
|
||||
<a href="javascript:void(0)" {{on "click" this.closeConnect}} data-test-button="close-explore">
|
||||
{{svg-jar "close"}}<span class="hidden">Close</span>
|
||||
</LinkTo>
|
||||
</a>
|
||||
</div>
|
||||
<div class="explore-content">
|
||||
|
@ -258,6 +258,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-expandable-block">
|
||||
<div class="gh-expandable-header">
|
||||
<div>
|
||||
<h4 class="gh-expandable-title">Explore app</h4>
|
||||
<p class="gh-expandable-description">
|
||||
Enable the Explore iframe app.
|
||||
</p>
|
||||
</div>
|
||||
<div class="for-switch">
|
||||
<GhFeatureFlag @flag="exploreApp" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
@ -6,7 +6,7 @@ describe('Unit | Route | explore', function () {
|
||||
setupTest();
|
||||
|
||||
it('exists', function () {
|
||||
let route = this.owner.lookup('route:explore');
|
||||
let route = this.owner.lookup('route:explore.connect');
|
||||
expect(route).to.be.ok;
|
||||
});
|
||||
});
|
||||
|
@ -33,7 +33,8 @@ const ALPHA_FEATURES = [
|
||||
'urlCache',
|
||||
'beforeAfterCard',
|
||||
'sourceAttribution',
|
||||
'lexicalEditor'
|
||||
'lexicalEditor',
|
||||
'exploreApp'
|
||||
];
|
||||
|
||||
module.exports.GA_KEYS = [...GA_FEATURES];
|
||||
|
Loading…
Reference in New Issue
Block a user