You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

599 lines
20 KiB

6 years ago
6 years ago
5 years ago
5 years ago
6 years ago
5 years ago
5 years ago
6 years ago
6 years ago
6 years ago
5 years ago
5 years ago
6 years ago
6 years ago
6 years ago
  1. <template lang="pug">
  2. v-app.editor(:dark='$vuetify.theme.dark')
  3. nav-header(dense)
  4. template(slot='mid')
  5. v-text-field.editor-title-input(
  6. dark
  7. solo
  8. flat
  9. v-model='currentPageTitle'
  10. hide-details
  11. background-color='black'
  12. dense
  13. full-width
  14. )
  15. template(slot='actions')
  16. v-btn.mr-3.animated.fadeIn(color='amber', outlined, small, v-if='isConflict', @click='openConflict')
  17. .overline.amber--text.mr-3 Conflict
  18. status-indicator(intermediary, pulse)
  19. v-btn.animated.fadeInDown(
  20. text
  21. color='green'
  22. @click.exact='save'
  23. @click.ctrl.exact='saveAndClose'
  24. :class='{ "is-icon": $vuetify.breakpoint.mdAndDown }'
  25. )
  26. v-icon(color='green', :left='$vuetify.breakpoint.lgAndUp') mdi-check
  27. span.grey--text(v-if='$vuetify.breakpoint.lgAndUp && mode !== `create` && !isDirty') {{ $t('editor:save.saved') }}
  28. span.white--text(v-else-if='$vuetify.breakpoint.lgAndUp') {{ mode === 'create' ? $t('common:actions.create') : $t('common:actions.save') }}
  29. v-btn.animated.fadeInDown.wait-p1s(
  30. text
  31. color='blue'
  32. @click='openPropsModal'
  33. :class='{ "is-icon": $vuetify.breakpoint.mdAndDown, "mx-0": !welcomeMode, "ml-0": welcomeMode }'
  34. )
  35. v-icon(color='blue', :left='$vuetify.breakpoint.lgAndUp') mdi-tag-text-outline
  36. span.white--text(v-if='$vuetify.breakpoint.lgAndUp') {{ $t('common:actions.page') }}
  37. v-btn.animated.fadeInDown.wait-p2s(
  38. v-if='!welcomeMode'
  39. text
  40. color='red'
  41. :class='{ "is-icon": $vuetify.breakpoint.mdAndDown }'
  42. @click='exit'
  43. )
  44. v-icon(color='red', :left='$vuetify.breakpoint.lgAndUp') mdi-close
  45. span.white--text(v-if='$vuetify.breakpoint.lgAndUp') {{ $t('common:actions.close') }}
  46. v-divider.ml-3(vertical)
  47. v-content
  48. component(:is='currentEditor', :save='save')
  49. editor-modal-properties(v-model='dialogProps')
  50. editor-modal-editorselect(v-model='dialogEditorSelector')
  51. editor-modal-unsaved(v-model='dialogUnsaved', @discard='exitGo')
  52. component(:is='activeModal')
  53. loader(v-model='dialogProgress', :title='$t(`editor:save.processing`)', :subtitle='$t(`editor:save.pleaseWait`)')
  54. notify
  55. </template>
  56. <script>
  57. import _ from 'lodash'
  58. import gql from 'graphql-tag'
  59. import { get, sync } from 'vuex-pathify'
  60. import { AtomSpinner } from 'epic-spinners'
  61. import { Base64 } from 'js-base64'
  62. import { StatusIndicator } from 'vue-status-indicator'
  63. import editorStore from '../store/editor'
  64. /* global WIKI */
  65. WIKI.$store.registerModule('editor', editorStore)
  66. export default {
  67. i18nOptions: { namespaces: 'editor' },
  68. components: {
  69. AtomSpinner,
  70. StatusIndicator,
  71. editorApi: () => import(/* webpackChunkName: "editor-api", webpackMode: "lazy" */ './editor/editor-api.vue'),
  72. editorCode: () => import(/* webpackChunkName: "editor-code", webpackMode: "lazy" */ './editor/editor-code.vue'),
  73. editorCkeditor: () => import(/* webpackChunkName: "editor-ckeditor", webpackMode: "lazy" */ './editor/editor-ckeditor.vue'),
  74. editorMarkdown: () => import(/* webpackChunkName: "editor-markdown", webpackMode: "lazy" */ './editor/editor-markdown.vue'),
  75. editorRedirect: () => import(/* webpackChunkName: "editor-redirect", webpackMode: "lazy" */ './editor/editor-redirect.vue'),
  76. editorModalEditorselect: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-editorselect.vue'),
  77. editorModalProperties: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-properties.vue'),
  78. editorModalUnsaved: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-unsaved.vue'),
  79. editorModalMedia: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-media.vue'),
  80. editorModalBlocks: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-blocks.vue'),
  81. editorModalConflict: () => import(/* webpackChunkName: "editor-conflict", webpackMode: "lazy" */ './editor/editor-modal-conflict.vue')
  82. },
  83. props: {
  84. locale: {
  85. type: String,
  86. default: 'en'
  87. },
  88. path: {
  89. type: String,
  90. default: 'home'
  91. },
  92. title: {
  93. type: String,
  94. default: 'Untitled Page'
  95. },
  96. description: {
  97. type: String,
  98. default: ''
  99. },
  100. tags: {
  101. type: Array,
  102. default: () => ([])
  103. },
  104. isPublished: {
  105. type: Boolean,
  106. default: true
  107. },
  108. scriptCss: {
  109. type: String,
  110. default: ''
  111. },
  112. publishStartDate: {
  113. type: String,
  114. default: ''
  115. },
  116. publishEndDate: {
  117. type: String,
  118. default: ''
  119. },
  120. scriptJs: {
  121. type: String,
  122. default: ''
  123. },
  124. initEditor: {
  125. type: String,
  126. default: null
  127. },
  128. initMode: {
  129. type: String,
  130. default: 'create'
  131. },
  132. initContent: {
  133. type: String,
  134. default: null
  135. },
  136. pageId: {
  137. type: Number,
  138. default: 0
  139. },
  140. checkoutDate: {
  141. type: String,
  142. default: new Date().toISOString()
  143. },
  144. effectivePermissions: {
  145. type: String,
  146. default: ''
  147. }
  148. },
  149. data() {
  150. return {
  151. isSaving: false,
  152. isConflict: false,
  153. dialogProps: false,
  154. dialogProgress: false,
  155. dialogEditorSelector: false,
  156. dialogUnsaved: false,
  157. exitConfirmed: false,
  158. initContentParsed: '',
  159. savedState: {
  160. description: '',
  161. isPublished: false,
  162. publishEndDate: '',
  163. publishStartDate: '',
  164. tags: '',
  165. title: '',
  166. css: '',
  167. js: ''
  168. }
  169. }
  170. },
  171. computed: {
  172. currentEditor: sync('editor/editor'),
  173. activeModal: sync('editor/activeModal'),
  174. mode: get('editor/mode'),
  175. welcomeMode() { return this.mode === `create` && this.path === `home` },
  176. currentPageTitle: sync('page/title'),
  177. checkoutDateActive: sync('editor/checkoutDateActive'),
  178. currentStyling: get('page/scriptCss'),
  179. isDirty () {
  180. return _.some([
  181. this.initContentParsed !== this.$store.get('editor/content'),
  182. this.locale !== this.$store.get('page/locale'),
  183. this.path !== this.$store.get('page/path'),
  184. this.savedState.title !== this.$store.get('page/title'),
  185. this.savedState.description !== this.$store.get('page/description'),
  186. this.savedState.tags !== this.$store.get('page/tags'),
  187. this.savedState.isPublished !== this.$store.get('page/isPublished'),
  188. this.savedState.publishStartDate !== this.$store.get('page/publishStartDate'),
  189. this.savedState.publishEndDate !== this.$store.get('page/publishEndDate'),
  190. this.savedState.css !== this.$store.get('page/scriptCss'),
  191. this.savedState.js !== this.$store.get('page/scriptJs')
  192. ], Boolean)
  193. }
  194. },
  195. watch: {
  196. currentEditor(newValue, oldValue) {
  197. if (newValue !== '' && this.mode === 'create') {
  198. _.delay(() => {
  199. this.dialogProps = true
  200. }, 500)
  201. }
  202. },
  203. currentStyling(newValue) {
  204. this.injectCustomCss(newValue)
  205. }
  206. },
  207. created() {
  208. this.$store.set('page/id', this.pageId)
  209. this.$store.set('page/description', this.description)
  210. this.$store.set('page/isPublished', this.isPublished)
  211. this.$store.set('page/publishStartDate', this.publishStartDate)
  212. this.$store.set('page/publishEndDate', this.publishEndDate)
  213. this.$store.set('page/locale', this.locale)
  214. this.$store.set('page/path', this.path)
  215. this.$store.set('page/tags', this.tags)
  216. this.$store.set('page/title', this.title)
  217. this.$store.set('page/scriptCss', this.scriptCss)
  218. this.$store.set('page/scriptJs', this.scriptJs)
  219. this.$store.set('page/mode', 'edit')
  220. this.setCurrentSavedState()
  221. this.checkoutDateActive = this.checkoutDate
  222. if (this.effectivePermissions) {
  223. this.$store.set('page/effectivePermissions', JSON.parse(Buffer.from(this.effectivePermissions, 'base64').toString()))
  224. }
  225. },
  226. mounted() {
  227. this.$store.set('editor/mode', this.initMode || 'create')
  228. this.initContentParsed = this.initContent ? Base64.decode(this.initContent) : ''
  229. this.$store.set('editor/content', this.initContentParsed)
  230. if (this.mode === 'create' && !this.initEditor) {
  231. _.delay(() => {
  232. this.dialogEditorSelector = true
  233. }, 500)
  234. } else {
  235. this.currentEditor = `editor${_.startCase(this.initEditor || 'markdown')}`
  236. }
  237. window.onbeforeunload = () => {
  238. if (!this.exitConfirmed && this.initContentParsed !== this.$store.get('editor/content')) {
  239. return this.$t('editor:unsavedWarning')
  240. } else {
  241. return undefined
  242. }
  243. }
  244. this.$root.$on('resetEditorConflict', () => {
  245. this.isConflict = false
  246. })
  247. // this.$store.set('editor/mode', 'edit')
  248. // this.currentEditor = `editorApi`
  249. },
  250. methods: {
  251. openPropsModal(name) {
  252. this.dialogProps = true
  253. },
  254. showProgressDialog(textKey) {
  255. this.dialogProgress = true
  256. },
  257. hideProgressDialog() {
  258. this.dialogProgress = false
  259. },
  260. openConflict() {
  261. this.$root.$emit('saveConflict')
  262. },
  263. async save({ rethrow = false, overwrite = false } = {}) {
  264. this.showProgressDialog('saving')
  265. this.isSaving = true
  266. const saveTimeoutHandle = setTimeout(() => {
  267. throw new Error('Save operation timed out.')
  268. }, 30000)
  269. try {
  270. if (this.$store.get('editor/mode') === 'create') {
  271. // --------------------------------------------
  272. // -> CREATE PAGE
  273. // --------------------------------------------
  274. let resp = await this.$apollo.mutate({
  275. mutation: gql`
  276. mutation (
  277. $content: String!
  278. $description: String!
  279. $editor: String!
  280. $isPrivate: Boolean!
  281. $isPublished: Boolean!
  282. $locale: String!
  283. $path: String!
  284. $publishEndDate: Date
  285. $publishStartDate: Date
  286. $scriptCss: String
  287. $scriptJs: String
  288. $tags: [String]!
  289. $title: String!
  290. ) {
  291. pages {
  292. create(
  293. content: $content
  294. description: $description
  295. editor: $editor
  296. isPrivate: $isPrivate
  297. isPublished: $isPublished
  298. locale: $locale
  299. path: $path
  300. publishEndDate: $publishEndDate
  301. publishStartDate: $publishStartDate
  302. scriptCss: $scriptCss
  303. scriptJs: $scriptJs
  304. tags: $tags
  305. title: $title
  306. ) {
  307. responseResult {
  308. succeeded
  309. errorCode
  310. slug
  311. message
  312. }
  313. page {
  314. id
  315. updatedAt
  316. }
  317. }
  318. }
  319. }
  320. `,
  321. variables: {
  322. content: this.$store.get('editor/content'),
  323. description: this.$store.get('page/description'),
  324. editor: this.$store.get('editor/editorKey'),
  325. locale: this.$store.get('page/locale'),
  326. isPrivate: false,
  327. isPublished: this.$store.get('page/isPublished'),
  328. path: this.$store.get('page/path'),
  329. publishEndDate: this.$store.get('page/publishEndDate') || '',
  330. publishStartDate: this.$store.get('page/publishStartDate') || '',
  331. scriptCss: this.$store.get('page/scriptCss'),
  332. scriptJs: this.$store.get('page/scriptJs'),
  333. tags: this.$store.get('page/tags'),
  334. title: this.$store.get('page/title')
  335. }
  336. })
  337. resp = _.get(resp, 'data.pages.create', {})
  338. if (_.get(resp, 'responseResult.succeeded')) {
  339. this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
  340. this.isConflict = false
  341. this.$store.commit('showNotification', {
  342. message: this.$t('editor:save.createSuccess'),
  343. style: 'success',
  344. icon: 'check'
  345. })
  346. this.$store.set('editor/id', _.get(resp, 'page.id'))
  347. this.$store.set('editor/mode', 'update')
  348. this.exitConfirmed = true
  349. window.location.assign(`/${this.$store.get('page/locale')}/${this.$store.get('page/path')}`)
  350. } else {
  351. throw new Error(_.get(resp, 'responseResult.message'))
  352. }
  353. } else {
  354. // --------------------------------------------
  355. // -> UPDATE EXISTING PAGE
  356. // --------------------------------------------
  357. const conflictResp = await this.$apollo.query({
  358. query: gql`
  359. query ($id: Int!, $checkoutDate: Date!) {
  360. pages {
  361. checkConflicts(id: $id, checkoutDate: $checkoutDate)
  362. }
  363. }
  364. `,
  365. fetchPolicy: 'network-only',
  366. variables: {
  367. id: this.pageId,
  368. checkoutDate: this.checkoutDateActive
  369. }
  370. })
  371. if (_.get(conflictResp, 'data.pages.checkConflicts', false)) {
  372. this.$root.$emit('saveConflict')
  373. throw new Error(this.$t('editor:conflict.warning'))
  374. }
  375. let resp = await this.$apollo.mutate({
  376. mutation: gql`
  377. mutation (
  378. $id: Int!
  379. $content: String
  380. $description: String
  381. $editor: String
  382. $isPrivate: Boolean
  383. $isPublished: Boolean
  384. $locale: String
  385. $path: String
  386. $publishEndDate: Date
  387. $publishStartDate: Date
  388. $scriptCss: String
  389. $scriptJs: String
  390. $tags: [String]
  391. $title: String
  392. ) {
  393. pages {
  394. update(
  395. id: $id
  396. content: $content
  397. description: $description
  398. editor: $editor
  399. isPrivate: $isPrivate
  400. isPublished: $isPublished
  401. locale: $locale
  402. path: $path
  403. publishEndDate: $publishEndDate
  404. publishStartDate: $publishStartDate
  405. scriptCss: $scriptCss
  406. scriptJs: $scriptJs
  407. tags: $tags
  408. title: $title
  409. ) {
  410. responseResult {
  411. succeeded
  412. errorCode
  413. slug
  414. message
  415. }
  416. page {
  417. updatedAt
  418. }
  419. }
  420. }
  421. }
  422. `,
  423. variables: {
  424. id: this.$store.get('page/id'),
  425. content: this.$store.get('editor/content'),
  426. description: this.$store.get('page/description'),
  427. editor: this.$store.get('editor/editorKey'),
  428. locale: this.$store.get('page/locale'),
  429. isPrivate: false,
  430. isPublished: this.$store.get('page/isPublished'),
  431. path: this.$store.get('page/path'),
  432. publishEndDate: this.$store.get('page/publishEndDate') || '',
  433. publishStartDate: this.$store.get('page/publishStartDate') || '',
  434. scriptCss: this.$store.get('page/scriptCss'),
  435. scriptJs: this.$store.get('page/scriptJs'),
  436. tags: this.$store.get('page/tags'),
  437. title: this.$store.get('page/title')
  438. }
  439. })
  440. resp = _.get(resp, 'data.pages.update', {})
  441. if (_.get(resp, 'responseResult.succeeded')) {
  442. this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
  443. this.isConflict = false
  444. this.$store.commit('showNotification', {
  445. message: this.$t('editor:save.updateSuccess'),
  446. style: 'success',
  447. icon: 'check'
  448. })
  449. if (this.locale !== this.$store.get('page/locale') || this.path !== this.$store.get('page/path')) {
  450. _.delay(() => {
  451. window.location.replace(`/e/${this.$store.get('page/locale')}/${this.$store.get('page/path')}`)
  452. }, 1000)
  453. }
  454. } else {
  455. throw new Error(_.get(resp, 'responseResult.message'))
  456. }
  457. }
  458. this.initContentParsed = this.$store.get('editor/content')
  459. this.setCurrentSavedState()
  460. } catch (err) {
  461. this.$store.commit('showNotification', {
  462. message: err.message,
  463. style: 'error',
  464. icon: 'warning'
  465. })
  466. if (rethrow === true) {
  467. clearTimeout(saveTimeoutHandle)
  468. this.isSaving = false
  469. this.hideProgressDialog()
  470. throw err
  471. }
  472. }
  473. clearTimeout(saveTimeoutHandle)
  474. this.isSaving = false
  475. this.hideProgressDialog()
  476. },
  477. async saveAndClose() {
  478. try {
  479. if (this.$store.get('editor/mode') === 'create') {
  480. await this.save()
  481. } else {
  482. await this.save({ rethrow: true })
  483. await this.exit()
  484. }
  485. } catch (err) {
  486. // Error is already handled
  487. }
  488. },
  489. async exit() {
  490. if (this.isDirty) {
  491. this.dialogUnsaved = true
  492. } else {
  493. this.exitGo()
  494. }
  495. },
  496. exitGo() {
  497. this.$store.commit(`loadingStart`, 'editor-close')
  498. this.currentEditor = ''
  499. this.exitConfirmed = true
  500. _.delay(() => {
  501. if (this.$store.get('editor/mode') === 'create') {
  502. window.location.assign(`/`)
  503. } else {
  504. window.location.assign(`/${this.$store.get('page/locale')}/${this.$store.get('page/path')}`)
  505. }
  506. }, 500)
  507. },
  508. setCurrentSavedState () {
  509. this.savedState = {
  510. description: this.$store.get('page/description'),
  511. isPublished: this.$store.get('page/isPublished'),
  512. publishEndDate: this.$store.get('page/publishEndDate') || '',
  513. publishStartDate: this.$store.get('page/publishStartDate') || '',
  514. tags: this.$store.get('page/tags'),
  515. title: this.$store.get('page/title'),
  516. css: this.$store.get('page/scriptCss'),
  517. js: this.$store.get('page/scriptJs')
  518. }
  519. },
  520. injectCustomCss: _.debounce(css => {
  521. const oldStyl = document.querySelector('#editor-script-css')
  522. if (oldStyl) {
  523. document.head.removeChild(oldStyl)
  524. }
  525. if (!_.isEmpty(css)) {
  526. const styl = document.createElement('style')
  527. styl.type = 'text/css'
  528. styl.id = 'editor-script-css'
  529. document.head.appendChild(styl)
  530. styl.appendChild(document.createTextNode(css))
  531. }
  532. }, 1000)
  533. },
  534. apollo: {
  535. isConflict: {
  536. query: gql`
  537. query ($id: Int!, $checkoutDate: Date!) {
  538. pages {
  539. checkConflicts(id: $id, checkoutDate: $checkoutDate)
  540. }
  541. }
  542. `,
  543. fetchPolicy: 'network-only',
  544. pollInterval: 5000,
  545. variables () {
  546. return {
  547. id: this.pageId,
  548. checkoutDate: this.checkoutDateActive
  549. }
  550. },
  551. update: (data) => _.cloneDeep(data.pages.checkConflicts),
  552. skip () {
  553. return this.mode === 'create' || this.isSaving || !this.isDirty
  554. }
  555. }
  556. }
  557. }
  558. </script>
  559. <style lang='scss'>
  560. .editor {
  561. background-color: mc('grey', '900') !important;
  562. min-height: 100vh;
  563. .application--wrap {
  564. background-color: mc('grey', '900');
  565. }
  566. &-title-input input {
  567. text-align: center;
  568. }
  569. }
  570. .atom-spinner.is-inline {
  571. display: inline-block;
  572. }
  573. </style>