WIP importer modal

no issue
This commit is contained in:
Sam Lord 2022-11-15 13:26:19 +00:00
parent fc291240d5
commit d5f8a2b59d
11 changed files with 767 additions and 37 deletions

View File

@ -0,0 +1,80 @@
<div class="gh-content-import-wrapper">
{{#if (eq this.state 'INIT')}}
<header class="modal-header" data-test-modal="import-content">
<h1>Import content</h1>
</header>
{{/if}}
{{#if (eq this.state 'PROCESSING')}}
<header class="modal-header icon-center" data-test-modal="import-content">
{{svg-jar "import-in-progress" class="gh-import-content-icon"}}
<h1>Import in progress</h1>
</header>
{{/if}}
{{#if (eq this.state 'ERROR')}}
<header class="modal-header" data-test-modal="import-content">
<h1>{{this.errorHeader}}</h1>
</header>
{{/if}}
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>
{{svg-jar "close"}}
<span class="hidden">Close</span>
</a>
<div class="modal-body">
{{#if (eq this.state 'INIT')}}
<ModalImportContent::ContentFileSelect @setFile={{action "setFile"}} />
{{/if}}
{{#if (eq this.state 'PROCESSING')}}
<div class="gh-content-import-resultcontainer">
<div class="gh-content-import-result-summary">
<p>Your import is being processed, and you&apos;ll receive a confirmation email as soon as it&apos;s complete. Usually this only takes a few minutes, but larger imports may take longer.</p>
</div>
</div>
{{/if}}
{{#if (eq this.state 'ERROR')}}
<div class="failed flex items-start gh-content-upload-errorcontainer error">
<div class="mr2">{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}</div>
<p class="ma0 pa0">{{this.errorMessage}}</p>
</div>
{{/if}}
</div>
<div class="modal-footer">
{{#if (eq this.state 'INIT')}}
<button class="gh-btn" data-test-button="close-import-content" type="button" {{action "closeModal"}}>
<span>Close</span>
</button>
{{/if}}
{{#if (eq this.state 'UPLOADING')}}
<button class="gh-btn disabled" disabled="disabled" data-test-button="restart-import-content" type="button" {{action "reset"}}>
<span>Start over</span>
</button>
<button class="gh-btn gh-btn-green gh-btn-icon disabled" disabled="disabled" type="button" {{action "upload"}}>
<span>{{svg-jar "spinner" class="gh-icon-spinner"}} {{this.runningText}}Uploading</span>
</button>
{{/if}}
{{#if (eq this.state 'PROCESSING')}}
<button class="gh-btn gh-btn-black" data-test-button="close-import-content" type="button" {{action "closeModal"}}>
<span>Got it</span>
</button>
{{/if}}
{{#if (eq this.state 'ERROR')}}
{{#if this.showTryAgainButton}}
<button class="gh-btn" data-test-button="restart-import-content" type="button" {{action "reset"}}>
<span>Try again</span>
</button>
{{/if}}
<button class="gh-btn gh-btn-black" data-test-button="close-import-content" type="button" {{action "closeModal"}}>
<span>OK</span>
</button>
{{/if}}
</div>
</div>

View File

@ -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);
}
});

View File

@ -0,0 +1,20 @@
{{#if this.error}}
<div class="failed flex items-start gh-content-upload-errorcontainer error">
<div class="mr2">{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}</div>
<p class="ma0 pa0">{{this.error.message}}</p>
</div>
{{/if}}
<div class="upload-form br3">
<section class="gh-image-uploader gh-content-import-uploader {{this.dragClass}}"
{{on "drop" this.drop}}
{{on "dragover" this.dragOver}}
{{on "dragleave" this.dragLeave}}
>
<GhFileInput @multiple={{false}} @alt={{this.labelText}} @action={{this.fileSelected}} @accept={{this.accept}} data-test-fileinput="content-import">
<div class="flex flex-column items-center">
{{svg-jar "upload"}}
<div class="description">{{this.labelText}}</div>
</div>
</GhFileInput>
</section>
</div>

View File

@ -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;
}
}

View File

@ -3,6 +3,7 @@ import {inject as service} from '@ember/service';
/* eslint-disable ghost/ember/alias-model-in-controller */ /* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import DeleteAllModal from '../../components/settings/labs/delete-all-content-modal'; import DeleteAllModal from '../../components/settings/labs/delete-all-content-modal';
import ImportContentModal from '../../components/modal-import-content';
import RSVP from 'rsvp'; import RSVP from 'rsvp';
import config from 'ghost-admin/config/environment'; import config from 'ghost-admin/config/environment';
import { import {
@ -139,6 +140,11 @@ export default class LabsController extends Controller {
}); });
} }
@action
importContent() {
return this.modals.open(ImportContentModal);
}
@action @action
downloadFile(endpoint) { downloadFile(endpoint) {
this.utils.downloadFile(this.ghostPaths.url.api(endpoint)); this.utils.downloadFile(this.ghostPaths.url.api(endpoint));

View File

@ -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');
}
}

View File

@ -103,6 +103,7 @@ Router.map(function () {
this.route('settings.navigation', {path: '/settings/navigation'}); this.route('settings.navigation', {path: '/settings/navigation'});
this.route('settings.labs', {path: '/settings/labs'}); this.route('settings.labs', {path: '/settings/labs'});
this.route('settings.labs.import', {path: '/settings/labs/import'});
this.route('members', function () { this.route('members', function () {
this.route('import'); this.route('import');

View File

@ -0,0 +1,3 @@
import AdminRoute from 'ghost-admin/routes/admin';
export default class LabsImportRoute extends AdminRoute {}

View File

@ -79,4 +79,434 @@
bottom: 1px; bottom: 1px;
width: 18px; width: 18px;
margin-right: 8px; margin-right: 8px;
} }
/* 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;
}
}

View File

@ -23,44 +23,13 @@
<div class="gh-expandable-header"> <div class="gh-expandable-header">
<div> <div>
<h4 class="gh-expandable-title">Import content</h4> <h4 class="gh-expandable-title">Import content</h4>
<p class="gh-expandable-description">Import posts from another Ghost installation</p> <p class="gh-expandable-description">Import posts from a JSON or zip file</p>
</div> </div>
<form id="settings-import" enctype="multipart/form-data"> <LinkTo @route="settings.labs.import" class="mr2" data-test-link="import-content">
<GhFileUpload <span>Open Importer</span>
@id="importfile" </LinkTo>
@classNames="flex" <button type="button" class="gh-btn" {{action "importContent"}}><span>Open Importer</span></button>
@uploadButtonText={{this.uploadButtonText}}
@onUpload={{action "onUpload"}}
@acceptEncoding={{this.importMimeType}}
data-test-file-input="import"
/>
</form>
</div> </div>
{{#if this.importErrors}}
<div class="gh-import-errors {{if this.importSuccessful "gh-import-errors-alert"}}" data-test-import-errors>
<div class="gh-import-errors-title">
{{#if this.importSuccessful}}
Import successful with warnings
{{else}}
Import failed
{{/if}}
</div>
{{#each this.importErrors as |error|}}
<div class="gh-import-error" data-test-import-error>
<p class="gh-import-error-message" data-test-import-error-message>
{{#if error.help}}{{error.help}}: {{/if}}{{error.message}}
</p>
{{#if error.context}}
<div class="gh-import-error-entry" data-test-import-error-context>
<pre>{{error.context}}</pre>
</div>
{{/if}}
</div>
{{/each}}
</div>
{{/if}}
</div> </div>
<div class="gh-expandable-block"> <div class="gh-expandable-block">

View File

@ -0,0 +1,4 @@
<GhFullscreenModal @modal="import-content"
@confirm={{this.close}}
@close={{this.close}}
@modifier="action import-content" />