diff --git a/ghost/admin/app/components/modal-import-content.hbs b/ghost/admin/app/components/modal-import-content.hbs new file mode 100644 index 0000000000..70f4f96f81 --- /dev/null +++ b/ghost/admin/app/components/modal-import-content.hbs @@ -0,0 +1,80 @@ +
+ {{#if (eq this.state 'INIT')}} + + {{/if}} + + {{#if (eq this.state 'PROCESSING')}} + + {{/if}} + + {{#if (eq this.state 'ERROR')}} + + {{/if}} + + + {{svg-jar "close"}} + + + + + + +
\ No newline at end of file diff --git a/ghost/admin/app/components/modal-import-content.js b/ghost/admin/app/components/modal-import-content.js new file mode 100644 index 0000000000..a8157e8c15 --- /dev/null +++ b/ghost/admin/app/components/modal-import-content.js @@ -0,0 +1,119 @@ +import ModalComponent from 'ghost-admin/components/modal-base'; +import ghostPaths from 'ghost-admin/utils/ghost-paths'; +import {computed} from '@ember/object'; +import {inject} from 'ghost-admin/decorators/inject'; +import { + isRequestEntityTooLargeError, + isUnsupportedMediaTypeError, + isVersionMismatchError +} from 'ghost-admin/services/ajax'; +import {inject as service} from '@ember/service'; + +export default ModalComponent.extend({ + ajax: service(), + notifications: service(), + store: service(), + + state: 'INIT', + + file: null, + paramName: 'importfile', + importResponse: null, + errorMessage: null, + errorHeader: null, + showTryAgainButton: true, + + // Allowed actions + confirm: () => {}, + + config: inject(), + + uploadUrl: computed(function () { + return `${ghostPaths().apiRoot}/db/importFile`; + }), + + formData: computed('file', function () { + let formData = new FormData(); + + formData.append(this.paramName, this.file); + + if (this.mappingResult.labels) { + this.mappingResult.labels.forEach((label) => { + formData.append('labels', label.name); + }); + } + + if (this.mappingResult.mapping) { + let mapping = this.mappingResult.mapping.toJSON(); + for (let [key, val] of Object.entries(mapping)) { + formData.append(`mapping[${key}]`, val); + } + } + + return formData; + }), + + actions: { + setFile(file) { + this.set('file', file); + this.generateRequest(); + }, + + reset() { + this.set('errorMessage', null); + this.set('errorHeader', null); + this.set('file', null); + this.set('state', 'INIT'); + this.set('showTryAgainButton', true); + }, + + closeModal() { + if (this.state !== 'UPLOADING') { + this._super(...arguments); + } + }, + + // noop - we don't want the enter key doing anything + confirm() {} + }, + + generateRequest() { + let ajax = this.ajax; + let formData = this.formData; + let url = this.uploadUrl; + + this.set('state', 'UPLOADING'); + ajax.post(url, { + data: formData, + processData: false, + contentType: false, + dataType: 'text' + }).then(() => { + this.set('state', 'PROCESSING'); + }).catch((error) => { + this._uploadError(error); + this.set('state', 'ERROR'); + }); + }, + + _uploadError(error) { + let message; + let header = 'Import error'; + + if (isVersionMismatchError(error)) { + this.notifications.showAPIError(error); + } + + if (isUnsupportedMediaTypeError(error)) { + message = 'The file type you uploaded is not supported.'; + } else if (isRequestEntityTooLargeError(error)) { + message = 'The file you uploaded was larger than the maximum file size your server allows.'; + } else { + console.error(error); // eslint-disable-line + message = 'Something went wrong :('; + } + + this.set('errorMessage', message); + this.set('errorHeader', header); + } +}); diff --git a/ghost/admin/app/components/modal-import-content/content-file-select.hbs b/ghost/admin/app/components/modal-import-content/content-file-select.hbs new file mode 100644 index 0000000000..ecd171655f --- /dev/null +++ b/ghost/admin/app/components/modal-import-content/content-file-select.hbs @@ -0,0 +1,20 @@ +{{#if this.error}} +
+
{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}
+

{{this.error.message}}

+
+{{/if}} +
+
+ +
+ {{svg-jar "upload"}} +
{{this.labelText}}
+
+
+
+
diff --git a/ghost/admin/app/components/modal-import-content/content-file-select.js b/ghost/admin/app/components/modal-import-content/content-file-select.js new file mode 100644 index 0000000000..c490f15d27 --- /dev/null +++ b/ghost/admin/app/components/modal-import-content/content-file-select.js @@ -0,0 +1,85 @@ +import Component from '@glimmer/component'; +import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax'; +import {action} from '@ember/object'; +import {tracked} from '@glimmer/tracking'; + +export default class ContentFileSelect extends Component { + labelText = 'Select or drop a JSON or zip file'; + + @tracked error = null; + @tracked dragClass = null; + + /* + constructor(...args) { + super(...args); + assert(this.args.setFile); + } + */ + + @action + fileSelected(fileList) { + console.log('File list: ' + JSON.stringify(fileList)); + + let [file] = Array.from(fileList); + + console.log('Validating file: ' + JSON.stringify(file)); + + try { + this._validateFileType(file); + this.error = null; + } catch (err) { + this.error = err; + return; + } + + console.log('Setting file to: ' + JSON.stringify(file)); + + this.args.setFile(file); + } + + @action + dragOver(event) { + if (!event.dataTransfer) { + return; + } + + // this is needed to work around inconsistencies with dropping files + // from Chrome's downloads bar + if (navigator.userAgent.indexOf('Chrome') > -1) { + let eA = event.dataTransfer.effectAllowed; + event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy'; + } + + event.stopPropagation(); + event.preventDefault(); + + this.dragClass = '-drag-over'; + } + + @action + dragLeave(event) { + event.preventDefault(); + this.dragClass = null; + } + + @action + drop(event) { + event.preventDefault(); + this.dragClass = null; + if (event.dataTransfer.files) { + this.fileSelected(event.dataTransfer.files); + } + } + + _validateFileType(file) { + let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); + + if (extension.toLowerCase() !== 'json' || extension.toLowerCase() !== 'zip') { + throw new UnsupportedMediaTypeError({ + message: 'The file type you uploaded is not supported' + }); + } + + return true; + } +} diff --git a/ghost/admin/app/controllers/settings/labs.js b/ghost/admin/app/controllers/settings/labs.js index 1c6a2640dd..94be24d115 100644 --- a/ghost/admin/app/controllers/settings/labs.js +++ b/ghost/admin/app/controllers/settings/labs.js @@ -3,6 +3,7 @@ import {inject as service} from '@ember/service'; /* eslint-disable ghost/ember/alias-model-in-controller */ import Controller from '@ember/controller'; import DeleteAllModal from '../../components/settings/labs/delete-all-content-modal'; +import ImportContentModal from '../../components/modal-import-content'; import RSVP from 'rsvp'; import config from 'ghost-admin/config/environment'; import { @@ -139,6 +140,11 @@ export default class LabsController extends Controller { }); } + @action + importContent() { + return this.modals.open(ImportContentModal); + } + @action downloadFile(endpoint) { this.utils.downloadFile(this.ghostPaths.url.api(endpoint)); diff --git a/ghost/admin/app/controllers/settings/labs/import.js b/ghost/admin/app/controllers/settings/labs/import.js new file mode 100644 index 0000000000..6b7cfd33c4 --- /dev/null +++ b/ghost/admin/app/controllers/settings/labs/import.js @@ -0,0 +1,13 @@ +import Controller, {inject as controller} from '@ember/controller'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; + +export default class ImportController extends Controller { + @service router; + @controller labs; + + @action + close() { + this.router.transitionTo('labs'); + } +} diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 2b8a852e74..e4134229df 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -103,6 +103,7 @@ Router.map(function () { this.route('settings.navigation', {path: '/settings/navigation'}); this.route('settings.labs', {path: '/settings/labs'}); + this.route('settings.labs.import', {path: '/settings/labs/import'}); this.route('members', function () { this.route('import'); diff --git a/ghost/admin/app/routes/settings/labs/import.js b/ghost/admin/app/routes/settings/labs/import.js new file mode 100644 index 0000000000..013cb3fab3 --- /dev/null +++ b/ghost/admin/app/routes/settings/labs/import.js @@ -0,0 +1,3 @@ +import AdminRoute from 'ghost-admin/routes/admin'; + +export default class LabsImportRoute extends AdminRoute {} diff --git a/ghost/admin/app/styles/layouts/labs.css b/ghost/admin/app/styles/layouts/labs.css index f4e23352b1..f0bb0332e2 100644 --- a/ghost/admin/app/styles/layouts/labs.css +++ b/ghost/admin/app/styles/layouts/labs.css @@ -79,4 +79,434 @@ bottom: 1px; width: 18px; margin-right: 8px; -} \ No newline at end of file +} + +/* Import modal +/* ---------------------------------------------------------- */ + +.fullscreen-modal-import-content { + max-width: unset !important; +} + +.gh-content-import-wrapper { + width: 420px; +} + +.gh-content-import-wrapper .gh-btn.disabled, +.gh-content-import-wrapper .gh-btn.disabled:hover { + cursor: auto !important; + opacity: 0.6 !important; +} + +.gh-content-import-wrapper .gh-btn.disabled span, +.gh-content-import-wrapper .gh-btn.disabled span:hover { + cursor: auto !important; + pointer-events: none; +} + +.gh-content-import-wrapper .gh-token-input .ember-power-select-trigger[aria-disabled=true], +.gh-content-import-wrapper .gh-token-input .ember-power-select-trigger-multiple-input:disabled { + background: var(--whitegrey-l2); +} + +@media (max-width: 600px) { + .gh-content-import-wrapper, + .gh-content-import-wrapper.wide { + width: calc(100vw - 128px); + } +} + +.gh-content-import-uploader { + width: 100%; + min-height: 180px; +} + +.gh-content-import-uploader svg { + width: 3.2rem; + height: 3.2rem; + margin-bottom: 1rem; +} + +.gh-content-import-uploader svg path { + stroke: var(--midlightgrey); +} + +.gh-content-import-uploader:hover svg path { + stroke: var(--midgrey-l1); +} + +.gh-content-import-uploader .description { + color: var(--midgrey); + font-size: 1.4rem; + font-weight: 500; +} + +.gh-content-import-uploader:hover .description { + color: var(--midgrey-d2); +} + +.gh-content-import-file { + min-height: 180px; +} + +.gh-content-import-spinner { + position: relative; + display: flex; + min-height: 182px; + justify-content: center; + align-items: center; + margin-bottom: -20px; +} + +.gh-content-import-spinner .gh-loading-content { + padding-bottom: 0px; +} + +.gh-content-import-spinner .description { + padding-top: 46px; +} + +.gh-content-upload-errorcontainer { + border: 1px solid var(--whitegrey); + border-radius: 4px; + padding: 12px; + margin-bottom: 24px; + color: var(--middarkgrey); +} + +.gh-content-upload-errorcontainer.warning { + border-left: 4px solid var(--yellow); +} + + +.gh-content-upload-errorcontainer.warning p a { + color: color-mod(var(--yellow) l(-12%)); + text-decoration: underline; +} + +.gh-content-upload-errorcontainer.error { + border-left: 4px solid var(--red); +} + +.gh-content-upload-errorcontainer.error p a { + color: var(--red); + text-decoration: underline; +} + +.gh-content-import-errormessage { + font-size: 1.25rem; + font-weight: 600; + margin: 12px 0 0; +} + +p.gh-content-import-errorcontext { + font-size: 1.25rem; + line-height: 1.3em; + margin: 0; + font-weight: 400; +} + +.gh-content-import-mapping .error { + color: var(--red); +} + +.gh-content-import-mappingwrapper.error { + position: relative; +} + +.gh-content-import-mappingwrapper.error::before { + display: block; + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 1px solid red; + z-index: 9999; + pointer-events: none; +} + +.gh-content-import-scrollarea { + position: relative; + max-height: calc(100vh - 350px - 12vw); + overflow-y: scroll; + margin: 0 -32px; + padding: 0 32px; + background: + /* Shadow covers */ + linear-gradient(var(--white) 30%, rgba(255,255,255,0)), + linear-gradient(rgba(255,255,255,0), var(--white) 70%) 0 100%, + + /* Shadows */ + /* radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.12), rgba(0,0,0,0)) -64px 0, + radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.12), rgba(0,0,0,0)) -64px 100%; */ + linear-gradient(rgba(0,0,0,0.08), rgba(0,0,0,0)), + linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.08)) 0 100%; + background-repeat: no-repeat; + background-color: var(--white); + background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; + + /* Opera doesn't support this in the shorthand */ + background-attachment: local, local, scroll, scroll; + margin-top: 4px; +} + +.gh-content-import-errorheading { + font-size: 1.4rem; + line-height: 1.55em; + margin-top: 2px; +} + +p.gh-content-import-errordetailtext { + font-size: 1.3rem; + line-height: 1.4em; + color: var(--midgrey); +} + +.gh-content-import-errordetailtext:first-of-type { + border-top: 1px solid var(--lightgrey); + padding-top: 8px; + margin-top: 8px; +} + +.gh-content-import-errordetailtext:not(:last-of-type) { + padding-bottom: 4px; + margin-bottom: 6px; +} + +.gh-content-import-table { + position: relative; + margin-bottom: 1px; +} + +.gh-content-import-table::before { + position: absolute; + display: block; + content: ""; + top: 0; + left: -33px; + bottom: 0; + height: 100%; + width: 32px; + background: var(--white); +} + +.gh-content-import-table::after { + position: absolute; + display: block; + content: ""; + top: 0; + right: -32px; + bottom: 0; + height: 100%; + width: 32px; + background: var(--white); +} + +.gh-content-import-table th { + padding: 3px 8px; + background: color-mod(var(--darkgrey) a(5%) s(+50%)); + border-left: 1px solid var(--content-import-table-border); + border-top: 1px solid var(--content-import-table-outline); + border-bottom: 1px solid var(--content-import-table-border); +} + +.gh-content-import-table tr th:first-of-type { + border-left: 1px solid var(--content-import-table-outline); + width: 180px; +} + +.gh-content-import-table tr th:last-of-type { + border-right: 1px solid var(--content-import-table-outline); +} + +.gh-content-import-table td.empty-cell { + background: color-mod(var(--darkgrey) a(3%) s(+50%)); +} + +.gh-content-import-table td { + padding: 7px 8px 6px; + border-left: 1px solid var(--content-import-table-border); + border-bottom: 1px solid var(--content-import-table-border); + vertical-align: top; +} + +.gh-content-import-table tr td:first-of-type { + border-left: 1px solid var(--content-import-table-outline); + width: 180px; +} + +.gh-content-import-table tr td:last-of-type { + padding: 0; + border-right: 1px solid var(--content-import-table-outline); +} + +.gh-content-import-table tr:last-of-type td { + border-bottom: 1px solid var(--content-import-table-outline); +} + +.gh-content-import-table td span, +.gh-content-import-table th span { + user-select: none !important; +} + +.gh-content-import-datanav { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 1px 2px rgba(0, 0, 0, 0.05); +} + +p.gh-content-import-errordetail { + font-size: 1.2rem; + line-height: 1.4em; + margin: 10px 0 0 24px; +} + +p.gh-content-import-errordetail:first-of-type { + border-top: 1px solid var(--whitegrey); + padding-top: 8px; + margin-top: 8px; +} + +.gh-import-content-select { + height: auto; + border: none; + background: none; + border-radius: 0; +} + +.gh-import-content-select select { + height: 34px; + border: none; + font-size: 1.3rem; + line-height: 1em; + padding: 4px 4px 4px 8px; + background: none; + color: var(--middarkgrey); + font-weight: 600; + border-radius: 0; +} + +.gh-import-content-select select option { + font-weight: 400; + color: var(--darkgrey); +} + +.gh-import-content-select select:focus { + background: none; + color: var(--middarkgrey); +} + +.gh-import-content-select.unmapped select, +.gh-import-content-select.unmapped select:focus { + color: var(--midlightgrey); + font-weight: 400; +} + +.gh-import-content-select svg { + right: 9px; +} + +.gh-content-import-table th.table-cell-field, +.gh-content-import-table td.table-cell-field, +.gh-content-import-table th.table-cell-data, +.gh-content-import-table td.table-cell-data { + max-width: 180px; + overflow-wrap: break-word; +} + +.gh-content-import-resultcontainer { + margin-bottom: 28px; +} + +.gh-content-import-result-summary { + flex-basis: 50%; +} + +.gh-content-import-result-summary h2 { + font-size: 3.6rem; + font-weight: 600; + margin: 0; + padding: 0; +} + +.gh-content-import-result-summary p { + color: var(--darkgrey); + margin: 0; + padding: 0; + line-height: 1.6em; + margin-bottom: 12px; +} + +.gh-content-import-result-summary p strong { + font-size: 1.5rem; + letter-spacing: 0; +} + +.gh-content-import-errorlist { + width: 100%; + margin: 8px 0 28px; +} + +.gh-content-import-errorlist h4 { + font-size: 13px; + font-weight: 500; + border-bottom: 1px solid var(--whitegrey); + padding-bottom: 8px; + margin-top: 0px; + color: var(--midgrey); +} + +.gh-content-import-errorlist ul li { + font-size: 13px; + font-weight: 400; + color: var(--midlightgrey-d2); + padding: 0; + margin-bottom: 6px; +} + +.gh-content-import-resultcontainer hr { + margin: 24px -32px; + border-color: var(--whitegrey); +} + +.gh-content-import-nodata span { + display: flex; + min-height: 144px; + align-items: center; + justify-content: center; + color: var(--midgrey); +} + +.gh-content-import-icon-content path, +.gh-content-import-icon-content circle { + stroke-width: 0.85px; +} + +.gh-content-import-icon-confetti { + color: var(--pink); + margin-left: 12px; +} + +.gh-content-import-icon-confetti path, +.gh-content-import-icon-confetti circle, +.gh-content-import-icon-confetti ellipse { + stroke-width: 0.85px; +} + +.gh-import-content-icon { + color: var(--darkgrey); + width: 54px !important; + height: 54px !important; + margin-right: -8px; +} + +.gh-import-content-icon * { + stroke-width: 0.8px !important; +} + +/* Fixing Firefox's select padding */ +@-moz-document url-prefix() { + .gh-import-content-select select { + padding: 4px; + } +} diff --git a/ghost/admin/app/templates/settings/labs.hbs b/ghost/admin/app/templates/settings/labs.hbs index 08a376abd6..5238190ef9 100644 --- a/ghost/admin/app/templates/settings/labs.hbs +++ b/ghost/admin/app/templates/settings/labs.hbs @@ -23,44 +23,13 @@

Import content

-

Import posts from another Ghost installation

+

Import posts from a JSON or zip file

-
- - + + Open Importer + +
- {{#if this.importErrors}} -
-
- {{#if this.importSuccessful}} - Import successful with warnings - {{else}} - Import failed - {{/if}} -
- - {{#each this.importErrors as |error|}} -
-

- {{#if error.help}}{{error.help}}: {{/if}}{{error.message}} -

- - {{#if error.context}} -
-
{{error.context}}
-
- {{/if}} -
- {{/each}} -
- {{/if}}
diff --git a/ghost/admin/app/templates/settings/labs/import.hbs b/ghost/admin/app/templates/settings/labs/import.hbs new file mode 100644 index 0000000000..3a840859eb --- /dev/null +++ b/ghost/admin/app/templates/settings/labs/import.hbs @@ -0,0 +1,4 @@ +