editor.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  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. initEditor: {
  109. type: String,
  110. default: null
  111. },
  112. initMode: {
  113. type: String,
  114. default: 'create'
  115. },
  116. initContent: {
  117. type: String,
  118. default: null
  119. },
  120. pageId: {
  121. type: Number,
  122. default: 0
  123. },
  124. checkoutDate: {
  125. type: String,
  126. default: new Date().toISOString()
  127. },
  128. effectivePermissions: {
  129. type: String,
  130. default: ''
  131. }
  132. },
  133. data() {
  134. return {
  135. isSaving: false,
  136. isConflict: false,
  137. dialogProps: false,
  138. dialogProgress: false,
  139. dialogEditorSelector: false,
  140. dialogUnsaved: false,
  141. exitConfirmed: false,
  142. initContentParsed: '',
  143. savedState: {
  144. description: '',
  145. isPublished: false,
  146. publishEndDate: '',
  147. publishStartDate: '',
  148. tags: '',
  149. title: ''
  150. }
  151. }
  152. },
  153. computed: {
  154. currentEditor: sync('editor/editor'),
  155. activeModal: sync('editor/activeModal'),
  156. mode: get('editor/mode'),
  157. welcomeMode() { return this.mode === `create` && this.path === `home` },
  158. currentPageTitle: sync('page/title'),
  159. checkoutDateActive: sync('editor/checkoutDateActive'),
  160. isDirty () {
  161. return _.some([
  162. this.initContentParsed !== this.$store.get('editor/content'),
  163. this.locale !== this.$store.get('page/locale'),
  164. this.path !== this.$store.get('page/path'),
  165. this.savedState.title !== this.$store.get('page/title'),
  166. this.savedState.description !== this.$store.get('page/description'),
  167. this.savedState.tags !== this.$store.get('page/tags'),
  168. this.savedState.isPublished !== this.$store.get('page/isPublished')
  169. ], Boolean)
  170. }
  171. },
  172. watch: {
  173. currentEditor(newValue, oldValue) {
  174. if (newValue !== '' && this.mode === 'create') {
  175. _.delay(() => {
  176. this.dialogProps = true
  177. }, 500)
  178. }
  179. }
  180. },
  181. created() {
  182. this.$store.commit('page/SET_ID', this.pageId)
  183. this.$store.commit('page/SET_DESCRIPTION', this.description)
  184. this.$store.commit('page/SET_IS_PUBLISHED', this.isPublished)
  185. this.$store.commit('page/SET_LOCALE', this.locale)
  186. this.$store.commit('page/SET_PATH', this.path)
  187. this.$store.commit('page/SET_TAGS', this.tags)
  188. this.$store.commit('page/SET_TITLE', this.title)
  189. this.$store.commit('page/SET_MODE', 'edit')
  190. this.setCurrentSavedState()
  191. this.checkoutDateActive = this.checkoutDate
  192. if (this.effectivePermissions) {
  193. this.$store.set('page/effectivePermissions', JSON.parse(Buffer.from(this.effectivePermissions, 'base64').toString()))
  194. }
  195. },
  196. mounted() {
  197. this.$store.set('editor/mode', this.initMode || 'create')
  198. this.initContentParsed = this.initContent ? Base64.decode(this.initContent) : ''
  199. this.$store.set('editor/content', this.initContentParsed)
  200. if (this.mode === 'create' && !this.initEditor) {
  201. _.delay(() => {
  202. this.dialogEditorSelector = true
  203. }, 500)
  204. } else {
  205. this.currentEditor = `editor${_.startCase(this.initEditor || 'markdown')}`
  206. }
  207. window.onbeforeunload = () => {
  208. if (!this.exitConfirmed && this.initContentParsed !== this.$store.get('editor/content')) {
  209. return this.$t('editor:unsavedWarning')
  210. } else {
  211. return undefined
  212. }
  213. }
  214. this.$root.$on('resetEditorConflict', () => {
  215. this.isConflict = false
  216. })
  217. // this.$store.set('editor/mode', 'edit')
  218. // this.currentEditor = `editorApi`
  219. },
  220. methods: {
  221. openPropsModal(name) {
  222. this.dialogProps = true
  223. },
  224. showProgressDialog(textKey) {
  225. this.dialogProgress = true
  226. },
  227. hideProgressDialog() {
  228. this.dialogProgress = false
  229. },
  230. openConflict() {
  231. this.$root.$emit('saveConflict')
  232. },
  233. async save({ rethrow = false, overwrite = false } = {}) {
  234. this.showProgressDialog('saving')
  235. this.isSaving = true
  236. const saveTimeoutHandle = setTimeout(() => {
  237. throw new Error('Save operation timed out.')
  238. }, 30000)
  239. try {
  240. if (this.$store.get('editor/mode') === 'create') {
  241. // --------------------------------------------
  242. // -> CREATE PAGE
  243. // --------------------------------------------
  244. let resp = await this.$apollo.mutate({
  245. mutation: gql`
  246. mutation (
  247. $content: String!
  248. $description: String!
  249. $editor: String!
  250. $isPrivate: Boolean!
  251. $isPublished: Boolean!
  252. $locale: String!
  253. $path: String!
  254. $publishEndDate: Date
  255. $publishStartDate: Date
  256. $scriptCss: String
  257. $scriptJs: String
  258. $tags: [String]!
  259. $title: String!
  260. ) {
  261. pages {
  262. create(
  263. content: $content
  264. description: $description
  265. editor: $editor
  266. isPrivate: $isPrivate
  267. isPublished: $isPublished
  268. locale: $locale
  269. path: $path
  270. publishEndDate: $publishEndDate
  271. publishStartDate: $publishStartDate
  272. scriptCss: $scriptCss
  273. scriptJs: $scriptJs
  274. tags: $tags
  275. title: $title
  276. ) {
  277. responseResult {
  278. succeeded
  279. errorCode
  280. slug
  281. message
  282. }
  283. page {
  284. id
  285. updatedAt
  286. }
  287. }
  288. }
  289. }
  290. `,
  291. variables: {
  292. content: this.$store.get('editor/content'),
  293. description: this.$store.get('page/description'),
  294. editor: this.$store.get('editor/editorKey'),
  295. locale: this.$store.get('page/locale'),
  296. isPrivate: false,
  297. isPublished: this.$store.get('page/isPublished'),
  298. path: this.$store.get('page/path'),
  299. publishEndDate: this.$store.get('page/publishEndDate') || '',
  300. publishStartDate: this.$store.get('page/publishStartDate') || '',
  301. scriptCss: this.$store.get('page/scriptCss'),
  302. scriptJs: this.$store.get('page/scriptJs'),
  303. tags: this.$store.get('page/tags'),
  304. title: this.$store.get('page/title')
  305. }
  306. })
  307. resp = _.get(resp, 'data.pages.create', {})
  308. if (_.get(resp, 'responseResult.succeeded')) {
  309. this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
  310. this.isConflict = false
  311. this.$store.commit('showNotification', {
  312. message: this.$t('editor:save.createSuccess'),
  313. style: 'success',
  314. icon: 'check'
  315. })
  316. this.$store.set('editor/id', _.get(resp, 'page.id'))
  317. this.$store.set('editor/mode', 'update')
  318. this.exitConfirmed = true
  319. window.location.assign(`/${this.$store.get('page/locale')}/${this.$store.get('page/path')}`)
  320. } else {
  321. throw new Error(_.get(resp, 'responseResult.message'))
  322. }
  323. } else {
  324. // --------------------------------------------
  325. // -> UPDATE EXISTING PAGE
  326. // --------------------------------------------
  327. const conflictResp = await this.$apollo.query({
  328. query: gql`
  329. query ($id: Int!, $checkoutDate: Date!) {
  330. pages {
  331. checkConflicts(id: $id, checkoutDate: $checkoutDate)
  332. }
  333. }
  334. `,
  335. fetchPolicy: 'network-only',
  336. variables: {
  337. id: this.pageId,
  338. checkoutDate: this.checkoutDateActive
  339. }
  340. })
  341. if (_.get(conflictResp, 'data.pages.checkConflicts', false)) {
  342. this.$root.$emit('saveConflict')
  343. throw new Error(this.$t('editor:conflict.warning'))
  344. }
  345. let resp = await this.$apollo.mutate({
  346. mutation: gql`
  347. mutation (
  348. $id: Int!
  349. $content: String
  350. $description: String
  351. $editor: String
  352. $isPrivate: Boolean
  353. $isPublished: Boolean
  354. $locale: String
  355. $path: String
  356. $publishEndDate: Date
  357. $publishStartDate: Date
  358. $scriptCss: String
  359. $scriptJs: String
  360. $tags: [String]
  361. $title: String
  362. ) {
  363. pages {
  364. update(
  365. id: $id
  366. content: $content
  367. description: $description
  368. editor: $editor
  369. isPrivate: $isPrivate
  370. isPublished: $isPublished
  371. locale: $locale
  372. path: $path
  373. publishEndDate: $publishEndDate
  374. publishStartDate: $publishStartDate
  375. scriptCss: $scriptCss
  376. scriptJs: $scriptJs
  377. tags: $tags
  378. title: $title
  379. ) {
  380. responseResult {
  381. succeeded
  382. errorCode
  383. slug
  384. message
  385. }
  386. page {
  387. updatedAt
  388. }
  389. }
  390. }
  391. }
  392. `,
  393. variables: {
  394. id: this.$store.get('page/id'),
  395. content: this.$store.get('editor/content'),
  396. description: this.$store.get('page/description'),
  397. editor: this.$store.get('editor/editorKey'),
  398. locale: this.$store.get('page/locale'),
  399. isPrivate: false,
  400. isPublished: this.$store.get('page/isPublished'),
  401. path: this.$store.get('page/path'),
  402. publishEndDate: this.$store.get('page/publishEndDate') || '',
  403. publishStartDate: this.$store.get('page/publishStartDate') || '',
  404. scriptCss: this.$store.get('page/scriptCss'),
  405. scriptJs: this.$store.get('page/scriptJs'),
  406. tags: this.$store.get('page/tags'),
  407. title: this.$store.get('page/title')
  408. }
  409. })
  410. resp = _.get(resp, 'data.pages.update', {})
  411. if (_.get(resp, 'responseResult.succeeded')) {
  412. this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
  413. this.isConflict = false
  414. this.$store.commit('showNotification', {
  415. message: this.$t('editor:save.updateSuccess'),
  416. style: 'success',
  417. icon: 'check'
  418. })
  419. if (this.locale !== this.$store.get('page/locale') || this.path !== this.$store.get('page/path')) {
  420. _.delay(() => {
  421. window.location.replace(`/e/${this.$store.get('page/locale')}/${this.$store.get('page/path')}`)
  422. }, 1000)
  423. }
  424. } else {
  425. throw new Error(_.get(resp, 'responseResult.message'))
  426. }
  427. }
  428. this.initContentParsed = this.$store.get('editor/content')
  429. this.setCurrentSavedState()
  430. } catch (err) {
  431. this.$store.commit('showNotification', {
  432. message: err.message,
  433. style: 'error',
  434. icon: 'warning'
  435. })
  436. if (rethrow === true) {
  437. clearTimeout(saveTimeoutHandle)
  438. this.isSaving = false
  439. this.hideProgressDialog()
  440. throw err
  441. }
  442. }
  443. clearTimeout(saveTimeoutHandle)
  444. this.isSaving = false
  445. this.hideProgressDialog()
  446. },
  447. async saveAndClose() {
  448. try {
  449. if (this.$store.get('editor/mode') === 'create') {
  450. await this.save()
  451. } else {
  452. await this.save({ rethrow: true })
  453. await this.exit()
  454. }
  455. } catch (err) {
  456. // Error is already handled
  457. }
  458. },
  459. async exit() {
  460. if (this.isDirty) {
  461. this.dialogUnsaved = true
  462. } else {
  463. this.exitGo()
  464. }
  465. },
  466. exitGo() {
  467. this.$store.commit(`loadingStart`, 'editor-close')
  468. this.currentEditor = ''
  469. this.exitConfirmed = true
  470. _.delay(() => {
  471. if (this.$store.get('editor/mode') === 'create') {
  472. window.location.assign(`/`)
  473. } else {
  474. window.location.assign(`/${this.$store.get('page/locale')}/${this.$store.get('page/path')}`)
  475. }
  476. }, 500)
  477. },
  478. setCurrentSavedState () {
  479. this.savedState = {
  480. description: this.$store.get('page/description'),
  481. isPublished: this.$store.get('page/isPublished'),
  482. publishEndDate: this.$store.get('page/publishEndDate') || '',
  483. publishStartDate: this.$store.get('page/publishStartDate') || '',
  484. tags: this.$store.get('page/tags'),
  485. title: this.$store.get('page/title')
  486. }
  487. }
  488. },
  489. apollo: {
  490. isConflict: {
  491. query: gql`
  492. query ($id: Int!, $checkoutDate: Date!) {
  493. pages {
  494. checkConflicts(id: $id, checkoutDate: $checkoutDate)
  495. }
  496. }
  497. `,
  498. fetchPolicy: 'network-only',
  499. pollInterval: 5000,
  500. variables () {
  501. return {
  502. id: this.pageId,
  503. checkoutDate: this.checkoutDateActive
  504. }
  505. },
  506. update: (data) => _.cloneDeep(data.pages.checkConflicts),
  507. skip () {
  508. return this.mode === 'create' || this.isSaving || !this.isDirty
  509. }
  510. }
  511. }
  512. }
  513. </script>
  514. <style lang='scss'>
  515. .editor {
  516. background-color: mc('grey', '900') !important;
  517. min-height: 100vh;
  518. .application--wrap {
  519. background-color: mc('grey', '900');
  520. }
  521. &-title-input input {
  522. text-align: center;
  523. }
  524. }
  525. .atom-spinner.is-inline {
  526. display: inline-block;
  527. }
  528. </style>