diff --git a/apps/admin-x-settings/src/components/settings/advanced/MigrationTools.tsx b/apps/admin-x-settings/src/components/settings/advanced/MigrationTools.tsx
new file mode 100644
index 0000000000..77079050bd
--- /dev/null
+++ b/apps/admin-x-settings/src/components/settings/advanced/MigrationTools.tsx
@@ -0,0 +1,39 @@
+import MigrationToolsExport from './migrationtools/MigrationToolsExport';
+import MigrationToolsImport from './migrationtools/MigrationToolsImport';
+import React, {useState} from 'react';
+import TopLevelGroup from '../../TopLevelGroup';
+import {SettingGroupHeader, Tab, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system';
+
+type MigrationTab = 'import' | 'export';
+
+const MigrationTools: React.FC<{ keywords: string[] }> = ({keywords}) => {
+ const [selectedTab, setSelectedTab] = useState
('import');
+
+ const tabs = [
+ {
+ id: 'import',
+ title: 'Import',
+ contents:
+ },
+ {
+ id: 'export',
+ title: 'Export',
+ contents:
+ }
+ ].filter(Boolean) as Tab[];
+
+ return (
+
+ }
+ keywords={keywords}
+ navid='migration'
+ testId='migrationtools'
+ >
+ selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
+
+ );
+};
+
+export default withErrorBoundary(MigrationTools, 'Migration tools');
diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx
index 17008d14c9..51ef6e5126 100644
--- a/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx
+++ b/apps/admin-x-settings/src/components/settings/advanced/labs/BetaFeatures.tsx
@@ -5,10 +5,8 @@ import {Button, FileUpload, List, showToast} from '@tryghost/admin-x-design-syst
import {downloadRedirects, useUploadRedirects} from '@tryghost/admin-x-framework/api/redirects';
import {downloadRoutes, useUploadRoutes} from '@tryghost/admin-x-framework/api/routes';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
-import {useRouting} from '@tryghost/admin-x-framework/routing';
const BetaFeatures: React.FC = () => {
- const {updateRoute} = useRouting();
const {mutateAsync: uploadRedirects} = useUploadRedirects();
const {mutateAsync: uploadRoutes} = useUploadRoutes();
const handleError = useHandleError();
@@ -17,10 +15,6 @@ const BetaFeatures: React.FC = () => {
return (
- updateRoute({isExternal: true, route: 'migrate'})} />}
- detail={<>A step-by-step tool to easily import all your content, members and paid subscriptions>}
- title='Substack migrator' />
}
detail={<>Translate your membership flows into your publication language (supported languages). Don’t see yours? Get involved>}
diff --git a/apps/admin-x-settings/src/components/settings/advanced/migrationtools/MigrationToolsExport.tsx b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/MigrationToolsExport.tsx
new file mode 100644
index 0000000000..99112c7ce8
--- /dev/null
+++ b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/MigrationToolsExport.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import {Button} from '@tryghost/admin-x-design-system';
+import {downloadAllContent} from '@tryghost/admin-x-framework/api/db';
+
+const MigrationToolsExport: React.FC = () => {
+ return (
+
+
Download all of your posts and settings in a single, glorious JSON file.
+
+ );
+};
+
+export default MigrationToolsExport;
diff --git a/apps/admin-x-settings/src/components/settings/advanced/migrationtools/MigrationToolsImport.tsx b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/MigrationToolsImport.tsx
new file mode 100644
index 0000000000..d1ff9bbf0f
--- /dev/null
+++ b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/MigrationToolsImport.tsx
@@ -0,0 +1,76 @@
+import NiceModal from '@ebay/nice-modal-react';
+import React from 'react';
+import UniversalImportModal from './UniversalImportModal';
+import clsx from 'clsx';
+import {Icon} from '@tryghost/admin-x-design-system';
+import {ReactComponent as MailchimpIcon} from '../../../../assets/icons/mailchimp.svg';
+import {ReactComponent as MediumIcon} from '../../../../assets/icons/medium.svg';
+import {ReactComponent as SubstackIcon} from '../../../../assets/icons/substack.svg';
+import {useRouting} from '@tryghost/admin-x-framework/routing';
+
+const ImportButton: React.FC<{
+ icon?: React.ReactNode,
+ title?: string,
+ onClick?: () => void
+}> = ({
+ icon,
+ title,
+ onClick
+}) => {
+ const classNames = clsx(
+ 'flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md bg-grey-100 px-2 text-sm font-semibold transition-all hover:bg-grey-200 dark:bg-grey-900'
+ );
+ if (onClick) {
+ return (
+
+ );
+ } else {
+ return <>>;
+ }
+};
+
+const MigrationToolsImport: React.FC = () => {
+ const {updateRoute} = useRouting();
+
+ const handleImportContent = () => {
+ NiceModal.show(UniversalImportModal);
+ };
+
+ return (
+
+
+ }
+ title='Substack'
+ onClick={() => updateRoute({isExternal: true, route: '/migrate/substack'})}
+ />
+
+ }
+ title='Medium'
+ onClick={() => updateRoute({isExternal: true, route: '/migrate/medium'})}
+ />
+
+ }
+ title='Mailchimp'
+ onClick={() => updateRoute({isExternal: true, route: '/migrate/mailchimp'})}
+ />
+
+ }
+ title='Universal import'
+ onClick={handleImportContent}
+ />
+
+ );
+};
+
+export default MigrationToolsImport;
diff --git a/apps/admin-x-settings/src/components/settings/advanced/migrationtools/UniversalImportModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/UniversalImportModal.tsx
new file mode 100644
index 0000000000..737b637888
--- /dev/null
+++ b/apps/admin-x-settings/src/components/settings/advanced/migrationtools/UniversalImportModal.tsx
@@ -0,0 +1,55 @@
+import NiceModal, {useModal} from '@ebay/nice-modal-react';
+import React, {useState} from 'react';
+import {ConfirmationModal, FileUpload, Modal} from '@tryghost/admin-x-design-system';
+import {useHandleError} from '@tryghost/admin-x-framework/hooks';
+import {useImportContent} from '@tryghost/admin-x-framework/api/db';
+
+const UniversalImportModal: React.FC = () => {
+ const modal = useModal();
+ const {mutateAsync: importContent} = useImportContent();
+ const [uploading, setUploading] = useState(false);
+ const handleError = useHandleError();
+
+ return (
+
+
+
{
+ setUploading(true);
+ try {
+ await importContent(file);
+ modal.remove();
+ NiceModal.show(ConfirmationModal, {
+ title: 'Import in progress',
+ prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
+ cancelLabel: '',
+ okLabel: 'Got it',
+ onOk: confirmModal => confirmModal?.remove(),
+ formSheet: false
+ });
+ } catch (e) {
+ handleError(e);
+ } finally {
+ setUploading(false);
+ }
+ }}
+ >
+
+ {uploading ? 'Uploading...' : <>
+ Select any JSON or zip file that contains
posts and settings
+ >}
+
+
+
+
+ );
+};
+
+export default NiceModal.create(UniversalImportModal);
diff --git a/apps/admin-x-settings/src/main.tsx b/apps/admin-x-settings/src/main.tsx
index 44aef09f02..a99e69c955 100644
--- a/apps/admin-x-settings/src/main.tsx
+++ b/apps/admin-x-settings/src/main.tsx
@@ -1,63 +1,46 @@
-import './styles/demo.css';
import './styles/index.css';
import App from './App.tsx';
-import React from 'react';
-import ReactDOM from 'react-dom/client';
-import {DefaultHeaderTypes} from './unsplash/UnsplashTypes.ts';
+import renderStandaloneApp from '@tryghost/admin-x-framework/test/render';
-ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
-
- {}}}
- framework={{
- externalNavigate: () => {},
- ghostVersion: '5.x',
- sentryDSN: null,
- unsplashConfig: {} as DefaultHeaderTypes,
- onDelete: () => {},
- onInvalidate: () => {},
- onUpdate: () => {}
- }}
- officialThemes={[{
- name: 'Source',
- category: 'News',
- previewUrl: 'https://source.ghost.io/',
- ref: 'default',
- image: 'assets/img/themes/Source.png',
- variants: [
- {
- category: 'Magazine',
- previewUrl: 'https://source-magazine.ghost.io/',
- image: 'assets/img/themes/Source-Magazine.png'
- },
- {
- category: 'Newsletter',
- previewUrl: 'https://source-newsletter.ghost.io/',
- image: 'assets/img/themes/Source-Newsletter.png'
- }
- ]
- }, {
- name: 'Casper',
- category: 'Blog',
- previewUrl: 'https://demo.ghost.io/',
- ref: 'default',
- image: 'assets/img/themes/Casper.png'
- }, {
- name: 'Headline',
- category: 'News',
- url: 'https://github.com/TryGhost/Headline',
- previewUrl: 'https://headline.ghost.io',
- ref: 'TryGhost/Headline',
- image: 'assets/img/themes/Headline.png'
- }, {
- name: 'Edition',
+renderStandaloneApp(App, {
+ officialThemes: [{
+ name: 'Source',
+ category: 'News',
+ previewUrl: 'https://source.ghost.io/',
+ ref: 'default',
+ image: 'assets/img/themes/Source.png',
+ variants: [
+ {
+ category: 'Magazine',
+ previewUrl: 'https://source-magazine.ghost.io/',
+ image: 'assets/img/themes/Source-Magazine.png'
+ },
+ {
category: 'Newsletter',
- url: 'https://github.com/TryGhost/Edition',
- previewUrl: 'https://edition.ghost.io/',
- ref: 'TryGhost/Edition',
- image: 'assets/img/themes/Edition.png'
- }]}
- zapierTemplates={[]}
- />
-
-);
+ previewUrl: 'https://source-newsletter.ghost.io/',
+ image: 'assets/img/themes/Source-Newsletter.png'
+ }
+ ]
+ }, {
+ name: 'Casper',
+ category: 'Blog',
+ previewUrl: 'https://demo.ghost.io/',
+ ref: 'default',
+ image: 'assets/img/themes/Casper.png'
+ }, {
+ name: 'Headline',
+ category: 'News',
+ url: 'https://github.com/TryGhost/Headline',
+ previewUrl: 'https://headline.ghost.io',
+ ref: 'TryGhost/Headline',
+ image: 'assets/img/themes/Headline.png'
+ }, {
+ name: 'Edition',
+ category: 'Newsletter',
+ url: 'https://github.com/TryGhost/Edition',
+ previewUrl: 'https://edition.ghost.io/',
+ ref: 'TryGhost/Edition',
+ image: 'assets/img/themes/Edition.png'
+ }],
+ zapierTemplates: []
+});
diff --git a/apps/admin-x-settings/src/styles/demo.css b/apps/admin-x-settings/src/styles/demo.css
deleted file mode 100644
index 8fd3a7ab6d..0000000000
--- a/apps/admin-x-settings/src/styles/demo.css
+++ /dev/null
@@ -1,18 +0,0 @@
-:root {
- font-size: 62.5%;
- line-height: 1.5;
- -ms-text-size-adjust: 100%;
- -webkit-text-size-adjust: 100%;
-
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- -webkit-text-size-adjust: 100%;
-}
-
-html, body, #root {
- width: 100%;
- height: 100%;
- margin: 0;
- letter-spacing: unset;
-}
diff --git a/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts b/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts
new file mode 100644
index 0000000000..6543926a91
--- /dev/null
+++ b/apps/admin-x-settings/test/acceptance/advanced/dangerzone.test.ts
@@ -0,0 +1,24 @@
+import {expect, test} from '@playwright/test';
+import {globalDataRequests} from '../../utils/acceptance';
+import {mockApi} from '@tryghost/admin-x-framework/test/acceptance';
+
+test.describe('DangerZone', async () => {
+ test('Delete all content', async ({page}) => {
+ const {lastApiRequests} = await mockApi({page, requests: {
+ ...globalDataRequests,
+ deleteAllContent: {method: 'DELETE', path: '/db/', response: {}}
+ }});
+
+ await page.goto('/');
+
+ const dangeZoneSection = page.getByTestId('dangerzone');
+
+ await dangeZoneSection.getByRole('button', {name: 'Delete all content'}).click();
+
+ await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Delete'}).click();
+
+ await expect(page.getByTestId('toast-success')).toContainText('All content deleted');
+
+ expect(lastApiRequests.deleteAllContent).toBeTruthy();
+ });
+});
diff --git a/apps/admin-x-settings/test/acceptance/advanced/labs.test.ts b/apps/admin-x-settings/test/acceptance/advanced/labs.test.ts
index daf98413ad..2b6f99d793 100644
--- a/apps/admin-x-settings/test/acceptance/advanced/labs.test.ts
+++ b/apps/admin-x-settings/test/acceptance/advanced/labs.test.ts
@@ -3,26 +3,6 @@ import {globalDataRequests} from '../../utils/acceptance';
import {mockApi} from '@tryghost/admin-x-framework/test/acceptance';
test.describe('Labs', async () => {
- test('Delete all content', async ({page}) => {
- const {lastApiRequests} = await mockApi({page, requests: {
- ...globalDataRequests,
- deleteAllContent: {method: 'DELETE', path: '/db/', response: {}}
- }});
-
- await page.goto('/');
-
- const labsSection = page.getByTestId('labs');
-
- await labsSection.getByRole('button', {name: 'Open'}).click();
- await labsSection.getByRole('button', {name: 'Delete'}).click();
-
- await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Delete'}).click();
-
- await expect(page.getByTestId('toast-success')).toContainText('All content deleted');
-
- expect(lastApiRequests.deleteAllContent).toBeTruthy();
- });
-
test('Uploading/downloading redirects', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
diff --git a/apps/admin-x-settings/test/acceptance/advanced/migrationTools.test.ts b/apps/admin-x-settings/test/acceptance/advanced/migrationTools.test.ts
new file mode 100644
index 0000000000..2601214fdd
--- /dev/null
+++ b/apps/admin-x-settings/test/acceptance/advanced/migrationTools.test.ts
@@ -0,0 +1,80 @@
+import {expect, test} from '@playwright/test';
+import {expectExternalNavigate, mockApi} from '@tryghost/admin-x-framework/test/acceptance';
+import {globalDataRequests} from '../../utils/acceptance';
+
+test.describe('Migration tools', async () => {
+ test('Built-in migrators', async ({page}) => {
+ await mockApi({page, requests: {
+ ...globalDataRequests
+ }});
+
+ await page.goto('/');
+
+ const migrationSection = page.getByTestId('migrationtools');
+
+ await migrationSection.getByRole('button', {name: 'Substack'}).click();
+ await expectExternalNavigate(page, {route: '/migrate/substack'});
+
+ await page.goto('/');
+
+ await migrationSection.getByRole('button', {name: 'Medium'}).click();
+ await expectExternalNavigate(page, {route: '/migrate/medium'});
+
+ await page.goto('/');
+
+ await migrationSection.getByRole('button', {name: 'Mailchimp'}).click();
+ await expectExternalNavigate(page, {route: '/migrate/mailchimp'});
+ });
+
+ test('Universal import', async ({page}) => {
+ const {lastApiRequests} = await mockApi({page, requests: {
+ ...globalDataRequests,
+ importContent: {path: '/db/', method: 'POST', response: {}}
+ }});
+
+ await page.goto('/');
+
+ const migrationSection = page.getByTestId('migrationtools');
+
+ await migrationSection.getByRole('button', {name: 'Universal import'}).click();
+
+ const universalImportModal = page.getByTestId('universal-import-modal');
+
+ const fileChooserPromise = page.waitForEvent('filechooser');
+
+ universalImportModal.getByText(/JSON or zip file/).click();
+
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(`${__dirname}/../../utils/files/upload.zip`);
+
+ const confirmationModal = page.getByTestId('confirmation-modal');
+
+ await expect(confirmationModal).toContainText('Import in progress');
+
+ await confirmationModal.getByRole('button', {name: 'Got it'}).click();
+
+ await expect(universalImportModal).not.toBeVisible();
+ await expect(confirmationModal).not.toBeVisible();
+
+ expect(lastApiRequests.importContent).toBeTruthy();
+ });
+
+ test('Content export', async ({page}) => {
+ const {lastApiRequests} = await mockApi({page, requests: {
+ ...globalDataRequests,
+ downloadAllContent: {path: '/db/', method: 'GET', response: {}}
+ }});
+
+ await page.goto('/');
+
+ const migrationSection = page.getByTestId('migrationtools');
+
+ await migrationSection.getByRole('tab', {name: 'Export'}).click();
+
+ await migrationSection.getByRole('button', {name: 'Export content'}).click();
+
+ await expect(page.locator('iframe#iframeDownload')).toHaveAttribute('src', /\/db\/$/);
+
+ expect(lastApiRequests.downloadAllContent).toBeTruthy();
+ });
+});
diff --git a/apps/admin-x-settings/test/utils/files/upload.zip b/apps/admin-x-settings/test/utils/files/upload.zip
new file mode 100644
index 0000000000..8fde66b696
Binary files /dev/null and b/apps/admin-x-settings/test/utils/files/upload.zip differ
diff --git a/ghost/admin/app/controllers/migrate.js b/ghost/admin/app/controllers/migrate.js
index f08060bfde..4aa0628652 100644
--- a/ghost/admin/app/controllers/migrate.js
+++ b/ghost/admin/app/controllers/migrate.js
@@ -12,6 +12,6 @@ export default class MigrateController extends Controller {
@action
closeMigrate() {
- this.router.transitionTo('/settings/labs');
+ this.router.transitionTo('/settings/migration');
}
}
diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js
index aff3bcd32a..e63f0eb076 100644
--- a/ghost/admin/app/router.js
+++ b/ghost/admin/app/router.js
@@ -71,7 +71,9 @@ Router.map(function () {
});
});
- this.route('migrate');
+ this.route('migrate', function () {
+ this.route('migrate', {path: '/*platform'});
+ });
this.route('members', function () {
this.route('import');
diff --git a/ghost/admin/app/services/migrate.js b/ghost/admin/app/services/migrate.js
index b4a692b260..9fac9c0308 100644
--- a/ghost/admin/app/services/migrate.js
+++ b/ghost/admin/app/services/migrate.js
@@ -15,6 +15,7 @@ export default class MigrateService extends Service {
@tracked siteData = null;
@tracked previousRoute = null;
@tracked isIframeTransition = false;
+ @tracked platform = null;
get apiUrl() {
const origin = window.location.origin;
@@ -71,6 +72,10 @@ export default class MigrateService extends Service {
getIframeURL() {
let url = this.migrateUrl;
+ const params = this.router.currentRoute.params;
+ if (params.platform) {
+ url = url + '?platform=' + params.platform;
+ }
return url;
}