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.

569 lines
18 KiB

  1. <template lang='pug'>
  2. v-app(:dark='$vuetify.theme.dark').history
  3. nav-header
  4. v-content
  5. v-toolbar(color='primary', dark)
  6. .subheading Viewing history of #[strong /{{path}}]
  7. template(v-if='$vuetify.breakpoint.mdAndUp')
  8. v-spacer
  9. .caption.blue--text.text--lighten-3.mr-4 Trail Length: {{total}}
  10. .caption.blue--text.text--lighten-3 ID: {{pageId}}
  11. v-btn.ml-4(depressed, color='blue darken-1', @click='goLive') Return to Live Version
  12. v-container(fluid, grid-list-xl)
  13. v-layout(row, wrap)
  14. v-flex(xs12, md4)
  15. v-chip.my-0.ml-6(
  16. label
  17. small
  18. :color='$vuetify.theme.dark ? `grey darken-2` : `grey lighten-2`'
  19. :class='$vuetify.theme.dark ? `grey--text text--lighten-2` : `grey--text text--darken-2`'
  20. )
  21. span Live
  22. v-timeline(
  23. dense
  24. )
  25. v-timeline-item.pb-2(
  26. v-for='(ph, idx) in fullTrail'
  27. :key='ph.versionId'
  28. :small='ph.actionType === `edit`'
  29. :color='trailColor(ph.actionType)'
  30. :icon='trailIcon(ph.actionType)'
  31. )
  32. v-card.radius-7(flat, :class='trailBgColor(ph.actionType)')
  33. v-toolbar(flat, :color='trailBgColor(ph.actionType)', height='40')
  34. .caption(:title='$options.filters.moment(ph.versionDate, `LLL`)') {{ ph.versionDate | moment('ll') }}
  35. v-divider.mx-3(vertical)
  36. .caption(v-if='ph.actionType === `edit`') Edited by #[strong {{ ph.authorName }}]
  37. .caption(v-else-if='ph.actionType === `move`') Moved from #[strong {{ph.valueBefore}}] to #[strong {{ph.valueAfter}}] by #[strong {{ ph.authorName }}]
  38. .caption(v-else-if='ph.actionType === `initial`') Created by #[strong {{ ph.authorName }}]
  39. .caption(v-else-if='ph.actionType === `live`') Last Edited by #[strong {{ ph.authorName }}]
  40. .caption(v-else) Unknown Action by #[strong {{ ph.authorName }}]
  41. v-spacer
  42. v-menu(offset-x, left)
  43. template(v-slot:activator='{ on }')
  44. v-btn.mr-2.radius-4(icon, v-on='on', small, tile): v-icon mdi-dots-horizontal
  45. v-list(dense, nav).history-promptmenu
  46. v-list-item(@click='setDiffSource(ph.versionId)', :disabled='(ph.versionId >= diffTarget && diffTarget !== 0) || ph.versionId === 0')
  47. v-list-item-avatar(size='24'): v-avatar A
  48. v-list-item-title Set as Differencing Source
  49. v-list-item(@click='setDiffTarget(ph.versionId)', :disabled='ph.versionId <= diffSource && ph.versionId !== 0')
  50. v-list-item-avatar(size='24'): v-avatar B
  51. v-list-item-title Set as Differencing Target
  52. v-list-item(@click='viewSource(ph.versionId)')
  53. v-list-item-avatar(size='24'): v-icon mdi-code-tags
  54. v-list-item-title View Source
  55. v-list-item(@click='download(ph.versionId)')
  56. v-list-item-avatar(size='24'): v-icon mdi-cloud-download-outline
  57. v-list-item-title Download Version
  58. v-list-item(@click='restore(ph.versionId, ph.versionDate)', :disabled='ph.versionId === 0')
  59. v-list-item-avatar(size='24'): v-icon(:disabled='ph.versionId === 0') mdi-history
  60. v-list-item-title Restore
  61. v-list-item(@click='branchOff(ph.versionId)')
  62. v-list-item-avatar(size='24'): v-icon mdi-source-branch
  63. v-list-item-title Branch off from here
  64. v-btn.mr-2.radius-4(
  65. @click='setDiffSource(ph.versionId)'
  66. icon
  67. small
  68. depressed
  69. tile
  70. :class='diffSource === ph.versionId ? `pink white--text` : ($vuetify.theme.dark ? `grey darken-2` : `grey lighten-2`)'
  71. :disabled='(ph.versionId >= diffTarget && diffTarget !== 0) || ph.versionId === 0'
  72. ): strong A
  73. v-btn.mr-0.radius-4(
  74. @click='setDiffTarget(ph.versionId)'
  75. icon
  76. small
  77. depressed
  78. tile
  79. :class='diffTarget === ph.versionId ? `pink white--text` : ($vuetify.theme.dark ? `grey darken-2` : `grey lighten-2`)'
  80. :disabled='ph.versionId <= diffSource && ph.versionId !== 0'
  81. ): strong B
  82. v-btn.ma-0.radius-7(
  83. v-if='total > trail.length'
  84. block
  85. color='primary'
  86. @click='loadMore'
  87. )
  88. .caption.white--text Load More...
  89. v-chip.ma-0(
  90. v-else
  91. label
  92. small
  93. :color='$vuetify.theme.dark ? `grey darken-2` : `grey lighten-2`'
  94. :class='$vuetify.theme.dark ? `grey--text text--lighten-2` : `grey--text text--darken-2`'
  95. ) End of history trail
  96. v-flex(xs12, md8)
  97. v-card.radius-7(:class='$vuetify.breakpoint.mdAndUp ? `mt-8` : ``')
  98. v-card-text
  99. v-card.grey.radius-7(flat, :class='$vuetify.theme.dark ? `darken-2` : `lighten-4`')
  100. v-row(no-gutters, align='center')
  101. v-col
  102. v-card-text
  103. .subheading {{target.title}}
  104. .caption {{target.description}}
  105. v-col.text-right.py-3(cols='2', v-if='$vuetify.breakpoint.mdAndUp')
  106. v-btn.mr-3(:color='$vuetify.theme.dark ? `white` : `grey darken-3`', small, dark, outlined, @click='toggleViewMode')
  107. v-icon(left) mdi-eye
  108. .overline View Mode
  109. v-card.mt-3(light, v-html='diffHTML', flat)
  110. v-dialog(v-model='isRestoreConfirmDialogShown', max-width='650', persistent)
  111. v-card
  112. .dialog-header.is-orange {{$t('history:restore.confirmTitle')}}
  113. v-card-text.pa-4
  114. i18next(tag='span', path='history:restore.confirmText')
  115. strong(place='date') {{ restoreTarget.versionDate | moment('LLL') }}
  116. v-card-actions
  117. v-spacer
  118. v-btn(text, @click='isRestoreConfirmDialogShown = false', :disabled='restoreLoading') {{$t('common:actions.cancel')}}
  119. v-btn(color='orange darken-2', dark, @click='restoreConfirm', :loading='restoreLoading') {{$t('history:restore.confirmButton')}}
  120. page-selector(mode='create', v-model='branchOffOpts.modal', :open-handler='branchOffHandle', :path='branchOffOpts.path', :locale='branchOffOpts.locale')
  121. nav-footer
  122. notify
  123. search-results
  124. </template>
  125. <script>
  126. import * as Diff2Html from 'diff2html'
  127. import { createPatch } from 'diff'
  128. import _ from 'lodash'
  129. import gql from 'graphql-tag'
  130. export default {
  131. i18nOptions: { namespaces: 'history' },
  132. props: {
  133. pageId: {
  134. type: Number,
  135. default: 0
  136. },
  137. locale: {
  138. type: String,
  139. default: 'en'
  140. },
  141. path: {
  142. type: String,
  143. default: 'home'
  144. },
  145. title: {
  146. type: String,
  147. default: 'Untitled Page'
  148. },
  149. description: {
  150. type: String,
  151. default: ''
  152. },
  153. createdAt: {
  154. type: String,
  155. default: ''
  156. },
  157. updatedAt: {
  158. type: String,
  159. default: ''
  160. },
  161. tags: {
  162. type: Array,
  163. default: () => ([])
  164. },
  165. authorName: {
  166. type: String,
  167. default: 'Unknown'
  168. },
  169. authorId: {
  170. type: Number,
  171. default: 0
  172. },
  173. isPublished: {
  174. type: Boolean,
  175. default: false
  176. },
  177. liveContent: {
  178. type: String,
  179. default: ''
  180. }
  181. },
  182. data () {
  183. return {
  184. source: {
  185. versionId: 0,
  186. content: '',
  187. title: '',
  188. description: ''
  189. },
  190. target: {
  191. versionId: 0,
  192. content: '',
  193. title: '',
  194. description: ''
  195. },
  196. trail: [],
  197. diffSource: 0,
  198. diffTarget: 0,
  199. offsetPage: 0,
  200. total: 0,
  201. viewMode: 'line-by-line',
  202. cache: [],
  203. restoreTarget: {
  204. versionId: 0,
  205. versionDate: ''
  206. },
  207. branchOffOpts: {
  208. versionId: 0,
  209. locale: 'en',
  210. path: 'new-page',
  211. modal: false
  212. },
  213. isRestoreConfirmDialogShown: false,
  214. restoreLoading: false
  215. }
  216. },
  217. computed: {
  218. fullTrail () {
  219. const liveTrailItem = {
  220. versionId: 0,
  221. authorId: this.authorId,
  222. authorName: this.authorName,
  223. actionType: 'live',
  224. valueBefore: null,
  225. valueAfter: null,
  226. versionDate: this.updatedAt
  227. }
  228. // -> Check for move between latest and live
  229. const prevPage = _.find(this.cache, ['versionId', _.get(this.trail, '[0].versionId', -1)])
  230. if (prevPage && this.path !== prevPage.path) {
  231. liveTrailItem.actionType = 'move'
  232. liveTrailItem.valueBefore = prevPage.path
  233. liveTrailItem.valueAfter = this.path
  234. }
  235. // -> Combine trail with live
  236. return [
  237. liveTrailItem,
  238. ...this.trail
  239. ]
  240. },
  241. diffs () {
  242. return createPatch(`/${this.path}`, this.source.content, this.target.content)
  243. },
  244. diffHTML () {
  245. return Diff2Html.html(this.diffs, {
  246. inputFormat: 'diff',
  247. drawFileList: false,
  248. matching: 'lines',
  249. outputFormat: this.viewMode
  250. })
  251. }
  252. },
  253. watch: {
  254. trail (newValue, oldValue) {
  255. if (newValue && newValue.length > 0) {
  256. this.diffTarget = 0
  257. this.diffSource = _.get(_.head(newValue), 'versionId', 0)
  258. }
  259. },
  260. async diffSource (newValue, oldValue) {
  261. if (this.diffSource !== this.source.versionId) {
  262. const page = _.find(this.cache, { versionId: newValue })
  263. if (page) {
  264. this.source = page
  265. } else {
  266. this.source = await this.loadVersion(newValue)
  267. }
  268. }
  269. },
  270. async diffTarget (newValue, oldValue) {
  271. if (this.diffTarget !== this.target.versionId) {
  272. const page = _.find(this.cache, { versionId: newValue })
  273. if (page) {
  274. this.target = page
  275. } else {
  276. this.target = await this.loadVersion(newValue)
  277. }
  278. }
  279. }
  280. },
  281. created () {
  282. this.$store.commit('page/SET_ID', this.id)
  283. this.$store.commit('page/SET_LOCALE', this.locale)
  284. this.$store.commit('page/SET_PATH', this.path)
  285. this.$store.commit('page/SET_MODE', 'history')
  286. this.cache.push({
  287. action: 'live',
  288. authorId: this.authorId,
  289. authorName: this.authorName,
  290. content: this.liveContent,
  291. contentType: '',
  292. createdAt: this.createdAt,
  293. description: this.description,
  294. editor: '',
  295. isPrivate: false,
  296. isPublished: this.isPublished,
  297. locale: this.locale,
  298. pageId: this.pageId,
  299. path: this.path,
  300. publishEndDate: '',
  301. publishStartDate: '',
  302. tags: this.tags,
  303. title: this.title,
  304. versionId: 0,
  305. versionDate: this.updatedAt
  306. })
  307. this.target = this.cache[0]
  308. },
  309. methods: {
  310. async loadVersion (versionId) {
  311. this.$store.commit(`loadingStart`, 'history-version-' + versionId)
  312. const resp = await this.$apollo.query({
  313. query: gql`
  314. query ($pageId: Int!, $versionId: Int!) {
  315. pages {
  316. version (pageId: $pageId, versionId: $versionId) {
  317. action
  318. authorId
  319. authorName
  320. content
  321. contentType
  322. createdAt
  323. versionDate
  324. description
  325. editor
  326. isPrivate
  327. isPublished
  328. locale
  329. pageId
  330. path
  331. publishEndDate
  332. publishStartDate
  333. tags
  334. title
  335. versionId
  336. }
  337. }
  338. }
  339. `,
  340. variables: {
  341. versionId,
  342. pageId: this.pageId
  343. }
  344. })
  345. this.$store.commit(`loadingStop`, 'history-version-' + versionId)
  346. const page = _.get(resp, 'data.pages.version', null)
  347. if (page) {
  348. this.cache.push(page)
  349. return page
  350. } else {
  351. return { content: '' }
  352. }
  353. },
  354. viewSource (versionId) {
  355. window.location.assign(`/s/${this.locale}/${this.path}?v=${versionId}`)
  356. },
  357. download (versionId) {
  358. window.location.assign(`/d/${this.locale}/${this.path}?v=${versionId}`)
  359. },
  360. restore (versionId, versionDate) {
  361. this.restoreTarget = {
  362. versionId,
  363. versionDate
  364. }
  365. this.isRestoreConfirmDialogShown = true
  366. },
  367. async restoreConfirm () {
  368. this.restoreLoading = true
  369. this.$store.commit(`loadingStart`, 'history-restore')
  370. try {
  371. const resp = await this.$apollo.mutate({
  372. mutation: gql`
  373. mutation ($pageId: Int!, $versionId: Int!) {
  374. pages {
  375. restore (pageId: $pageId, versionId: $versionId) {
  376. responseResult {
  377. succeeded
  378. errorCode
  379. slug
  380. message
  381. }
  382. }
  383. }
  384. }
  385. `,
  386. variables: {
  387. versionId: this.restoreTarget.versionId,
  388. pageId: this.pageId
  389. }
  390. })
  391. if (_.get(resp, 'data.pages.restore.responseResult.succeeded', false) === true) {
  392. this.$store.commit('showNotification', {
  393. style: 'success',
  394. message: this.$t('history:restore.success'),
  395. icon: 'check'
  396. })
  397. this.isRestoreConfirmDialogShown = false
  398. setTimeout(() => {
  399. window.location.assign(`/${this.locale}/${this.path}`)
  400. }, 1000)
  401. } else {
  402. throw new Error(_.get(resp, 'data.pages.restore.responseResult.message', 'An unexpected error occured'))
  403. }
  404. } catch (err) {
  405. this.$store.commit('showNotification', {
  406. style: 'red',
  407. message: err.message,
  408. icon: 'alert'
  409. })
  410. }
  411. this.$store.commit(`loadingStop`, 'history-restore')
  412. this.restoreLoading = false
  413. },
  414. branchOff (versionId) {
  415. const pathParts = this.path.split('/')
  416. this.branchOffOpts = {
  417. versionId: versionId,
  418. locale: this.locale,
  419. path: (pathParts.length > 1) ? _.initial(pathParts).join('/') + `/new-page` : `new-page`,
  420. modal: true
  421. }
  422. },
  423. branchOffHandle ({ locale, path }) {
  424. window.location.assign(`/e/${locale}/${path}?from=${this.pageId},${this.branchOffOpts.versionId}`)
  425. },
  426. toggleViewMode () {
  427. this.viewMode = (this.viewMode === 'line-by-line') ? 'side-by-side' : 'line-by-line'
  428. },
  429. goLive () {
  430. window.location.assign(`/${this.path}`)
  431. },
  432. setDiffSource (versionId) {
  433. this.diffSource = versionId
  434. },
  435. setDiffTarget (versionId) {
  436. this.diffTarget = versionId
  437. },
  438. loadMore () {
  439. this.offsetPage++
  440. this.$apollo.queries.trail.fetchMore({
  441. variables: {
  442. id: this.pageId,
  443. offsetPage: this.offsetPage,
  444. offsetSize: this.$vuetify.breakpoint.mdAndUp ? 25 : 5
  445. },
  446. updateQuery: (previousResult, { fetchMoreResult }) => {
  447. return {
  448. pages: {
  449. history: {
  450. total: previousResult.pages.history.total,
  451. trail: [...previousResult.pages.history.trail, ...fetchMoreResult.pages.history.trail],
  452. __typename: previousResult.pages.history.__typename
  453. },
  454. __typename: previousResult.pages.__typename
  455. }
  456. }
  457. }
  458. })
  459. },
  460. trailColor (actionType) {
  461. switch (actionType) {
  462. case 'edit':
  463. return 'primary'
  464. case 'move':
  465. return 'purple'
  466. case 'initial':
  467. return 'teal'
  468. case 'live':
  469. return 'orange'
  470. default:
  471. return 'grey'
  472. }
  473. },
  474. trailIcon (actionType) {
  475. switch (actionType) {
  476. case 'edit':
  477. return '' // 'mdi-pencil'
  478. case 'move':
  479. return 'mdi-forward'
  480. case 'initial':
  481. return 'mdi-plus'
  482. case 'live':
  483. return 'mdi-atom-variant'
  484. default:
  485. return 'mdi-alert'
  486. }
  487. },
  488. trailBgColor (actionType) {
  489. switch (actionType) {
  490. case 'move':
  491. return this.$vuetify.theme.dark ? 'purple' : 'purple lighten-5'
  492. case 'initial':
  493. return this.$vuetify.theme.dark ? 'teal darken-3' : 'teal lighten-5'
  494. case 'live':
  495. return this.$vuetify.theme.dark ? 'orange darken-3' : 'orange lighten-5'
  496. default:
  497. return this.$vuetify.theme.dark ? 'grey darken-3' : 'grey lighten-4'
  498. }
  499. }
  500. },
  501. apollo: {
  502. trail: {
  503. query: gql`
  504. query($id: Int!, $offsetPage: Int, $offsetSize: Int) {
  505. pages {
  506. history(id:$id, offsetPage:$offsetPage, offsetSize:$offsetSize) {
  507. trail {
  508. versionId
  509. authorId
  510. authorName
  511. actionType
  512. valueBefore
  513. valueAfter
  514. versionDate
  515. }
  516. total
  517. }
  518. }
  519. }
  520. `,
  521. variables () {
  522. return {
  523. id: this.pageId,
  524. offsetPage: 0,
  525. offsetSize: this.$vuetify.breakpoint.mdAndUp ? 25 : 5
  526. }
  527. },
  528. manual: true,
  529. result ({ data, loading, networkStatus }) {
  530. this.total = data.pages.history.total
  531. this.trail = data.pages.history.trail
  532. },
  533. watchLoading (isLoading) {
  534. this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'history-trail-refresh')
  535. }
  536. }
  537. }
  538. }
  539. </script>
  540. <style lang='scss'>
  541. .history {
  542. &-promptmenu {
  543. border-top: 5px solid mc('blue', '700');
  544. }
  545. .d2h-file-wrapper {
  546. border: 1px solid #EEE;
  547. border-left: none;
  548. }
  549. .d2h-file-header {
  550. display: none;
  551. }
  552. }
  553. </style>