diff --git a/ghost/admin/app/components/dashboard/resources/explore-feed.hbs b/ghost/admin/app/components/dashboard/resources/explore-feed.hbs index 7932b1e868..f6e7ea36b9 100644 --- a/ghost/admin/app/components/dashboard/resources/explore-feed.hbs +++ b/ghost/admin/app/components/dashboard/resources/explore-feed.hbs @@ -1,14 +1,14 @@
-

Featured publications +

Featured publications {{#if this.meta.category_url}} in {{this.meta.category}} {{/if}}

Browse all - Add your site to Explore + Add your site to Explore
diff --git a/ghost/admin/app/components/gh-explore-iframe.hbs b/ghost/admin/app/components/gh-explore-iframe.hbs new file mode 100644 index 0000000000..f74b7fef58 --- /dev/null +++ b/ghost/admin/app/components/gh-explore-iframe.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ghost/admin/app/components/gh-explore-iframe.js b/ghost/admin/app/components/gh-explore-iframe.js new file mode 100644 index 0000000000..3a50c68e3e --- /dev/null +++ b/ghost/admin/app/components/gh-explore-iframe.js @@ -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; + } +} diff --git a/ghost/admin/app/components/gh-explore-modal.hbs b/ghost/admin/app/components/gh-explore-modal.hbs new file mode 100644 index 0000000000..e493665fb8 --- /dev/null +++ b/ghost/admin/app/components/gh-explore-modal.hbs @@ -0,0 +1,5 @@ +
+
+ +
+
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-explore-modal.js b/ghost/admin/app/components/gh-explore-modal.js new file mode 100644 index 0000000000..90b5bf04b6 --- /dev/null +++ b/ghost/admin/app/components/gh-explore-modal.js @@ -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'; + } +} diff --git a/ghost/admin/app/components/gh-nav-menu/main.hbs b/ghost/admin/app/components/gh-nav-menu/main.hbs index 0e41523c7b..054e1bddc0 100644 --- a/ghost/admin/app/components/gh-nav-menu/main.hbs +++ b/ghost/admin/app/components/gh-nav-menu/main.hbs @@ -21,6 +21,11 @@
  • {{svg-jar "house"}} Dashboard
  • + {{#if (feature "exploreApp")}} +
  • + {{svg-jar "globe"}} Explore +
  • + {{/if}} {{/if}}
  • diff --git a/ghost/admin/app/controllers/application.js b/ghost/admin/app/controllers/application.js index 6a12401139..31826c38d7 100644 --- a/ghost/admin/app/controllers/application.js +++ b/ghost/admin/app/controllers/application.js @@ -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; diff --git a/ghost/admin/app/controllers/explore.js b/ghost/admin/app/controllers/explore.js index b2c43dbdad..e5edcdf137 100644 --- a/ghost/admin/app/controllers/explore.js +++ b/ghost/admin/app/controllers/explore.js @@ -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(); + } } } diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index a7ef180191..717ef4c2ac 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -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'); diff --git a/ghost/admin/app/routes/explore.js b/ghost/admin/app/routes/explore.js deleted file mode 100644 index 467c44e18a..0000000000 --- a/ghost/admin/app/routes/explore.js +++ /dev/null @@ -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'); - } -} diff --git a/ghost/admin/app/routes/explore/connect.js b/ghost/admin/app/routes/explore/connect.js new file mode 100644 index 0000000000..6ab296e8b3 --- /dev/null +++ b/ghost/admin/app/routes/explore/connect.js @@ -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); + } +} diff --git a/ghost/admin/app/routes/explore/explore-sub.js b/ghost/admin/app/routes/explore/explore-sub.js new file mode 100644 index 0000000000..f504100aab --- /dev/null +++ b/ghost/admin/app/routes/explore/explore-sub.js @@ -0,0 +1,5 @@ +import ExploreRoute from './index'; + +export default class ExploreSubRoute extends ExploreRoute { + controllerName = 'explore'; +} diff --git a/ghost/admin/app/routes/explore/index.js b/ghost/admin/app/routes/explore/index.js new file mode 100644 index 0000000000..429a8dee2c --- /dev/null +++ b/ghost/admin/app/routes/explore/index.js @@ -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' + }; + } +} diff --git a/ghost/admin/app/services/explore.js b/ghost/admin/app/services/explore.js new file mode 100644 index 0000000000..f8f9d73c20 --- /dev/null +++ b/ghost/admin/app/services/explore.js @@ -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'); + } +} diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index cb972fd9d5..6582e58be4 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -65,6 +65,7 @@ export default class FeatureService extends Service { @feature('emailAlerts') emailAlerts; @feature('sourceAttribution') sourceAttribution; @feature('lexicalEditor') lexicalEditor; + @feature('exploreApp') exploreApp; _user = null; diff --git a/ghost/admin/app/styles/layouts/explore.css b/ghost/admin/app/styles/layouts/explore.css index aa103c6b46..f016b981a9 100644 --- a/ghost/admin/app/styles/layouts/explore.css +++ b/ghost/admin/app/styles/layouts/explore.css @@ -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; diff --git a/ghost/admin/app/templates/application.hbs b/ghost/admin/app/templates/application.hbs index c304eef521..fecceb6f3f 100644 --- a/ghost/admin/app/templates/application.hbs +++ b/ghost/admin/app/templates/application.hbs @@ -14,6 +14,9 @@ {{#if this.showBilling}} {{/if}} + {{#if (feature "exploreApp")}} + + {{/if}} diff --git a/ghost/admin/app/templates/explore.hbs b/ghost/admin/app/templates/explore/connect.hbs similarity index 89% rename from ghost/admin/app/templates/explore.hbs rename to ghost/admin/app/templates/explore/connect.hbs index 31fadeb518..b757448952 100644 --- a/ghost/admin/app/templates/explore.hbs +++ b/ghost/admin/app/templates/explore/connect.hbs @@ -1,8 +1,8 @@ -
    +
    diff --git a/ghost/admin/app/templates/settings/labs.hbs b/ghost/admin/app/templates/settings/labs.hbs index 9f090dee66..29fc250e1e 100644 --- a/ghost/admin/app/templates/settings/labs.hbs +++ b/ghost/admin/app/templates/settings/labs.hbs @@ -258,6 +258,19 @@
    +
    +
    +
    +

    Explore app

    +

    + Enable the Explore iframe app. +

    +
    +
    + +
    +
    +
  • {{/if}} diff --git a/ghost/admin/tests/unit/routes/explore-test.js b/ghost/admin/tests/unit/routes/explore-test.js index 11090270a7..9f1a37b929 100644 --- a/ghost/admin/tests/unit/routes/explore-test.js +++ b/ghost/admin/tests/unit/routes/explore-test.js @@ -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; }); }); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 53317444e5..18e2b17276 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -33,7 +33,8 @@ const ALPHA_FEATURES = [ 'urlCache', 'beforeAfterCard', 'sourceAttribution', - 'lexicalEditor' + 'lexicalEditor', + 'exploreApp' ]; module.exports.GA_KEYS = [...GA_FEATURES];