history.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  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. effectivePermissions: {
  182. type: String,
  183. default: ''
  184. }
  185. },
  186. data () {
  187. return {
  188. source: {
  189. versionId: 0,
  190. content: '',
  191. title: '',
  192. description: ''
  193. },
  194. target: {
  195. versionId: 0,
  196. content: '',
  197. title: '',
  198. description: ''
  199. },
  200. trail: [],
  201. diffSource: 0,
  202. diffTarget: 0,
  203. offsetPage: 0,
  204. total: 0,
  205. viewMode: 'line-by-line',
  206. cache: [],
  207. restoreTarget: {
  208. versionId: 0,
  209. versionDate: ''
  210. },
  211. branchOffOpts: {
  212. versionId: 0,
  213. locale: 'en',
  214. path: 'new-page',
  215. modal: false
  216. },
  217. isRestoreConfirmDialogShown: false,
  218. restoreLoading: false
  219. }
  220. },
  221. computed: {
  222. fullTrail () {
  223. const liveTrailItem = {
  224. versionId: 0,
  225. authorId: this.authorId,
  226. authorName: this.authorName,
  227. actionType: 'live',
  228. valueBefore: null,
  229. valueAfter: null,
  230. versionDate: this.updatedAt
  231. }
  232. // -> Check for move between latest and live
  233. const prevPage = _.find(this.cache, ['versionId', _.get(this.trail, '[0].versionId', -1)])
  234. if (prevPage && this.path !== prevPage.path) {
  235. liveTrailItem.actionType = 'move'
  236. liveTrailItem.valueBefore = prevPage.path
  237. liveTrailItem.valueAfter = this.path
  238. }
  239. // -> Combine trail with live
  240. return [
  241. liveTrailItem,
  242. ...this.trail
  243. ]
  244. },
  245. diffs () {
  246. return createPatch(`/${this.path}`, this.source.content, this.target.content)
  247. },
  248. diffHTML () {
  249. return Diff2Html.html(this.diffs, {
  250. inputFormat: 'diff',
  251. drawFileList: false,
  252. matching: 'lines',
  253. outputFormat: this.viewMode
  254. })
  255. }
  256. },
  257. watch: {
  258. trail (newValue, oldValue) {
  259. if (newValue && newValue.length > 0) {
  260. this.diffTarget = 0
  261. this.diffSource = _.get(_.head(newValue), 'versionId', 0)
  262. }
  263. },
  264. async diffSource (newValue, oldValue) {
  265. if (this.diffSource !== this.source.versionId) {
  266. const page = _.find(this.cache, { versionId: newValue })
  267. if (page) {
  268. this.source = page
  269. } else {
  270. this.source = await this.loadVersion(newValue)
  271. }
  272. }
  273. },
  274. async diffTarget (newValue, oldValue) {
  275. if (this.diffTarget !== this.target.versionId) {
  276. const page = _.find(this.cache, { versionId: newValue })
  277. if (page) {
  278. this.target = page
  279. } else {
  280. this.target = await this.loadVersion(newValue)
  281. }
  282. }
  283. }
  284. },
  285. created () {
  286. this.$store.commit('page/SET_ID', this.id)
  287. this.$store.commit('page/SET_LOCALE', this.locale)
  288. this.$store.commit('page/SET_PATH', this.path)
  289. this.$store.commit('page/SET_MODE', 'history')
  290. this.cache.push({
  291. action: 'live',
  292. authorId: this.authorId,
  293. authorName: this.authorName,
  294. content: this.liveContent,
  295. contentType: '',
  296. createdAt: this.createdAt,
  297. description: this.description,
  298. editor: '',
  299. isPrivate: false,
  300. isPublished: this.isPublished,
  301. locale: this.locale,
  302. pageId: this.pageId,
  303. path: this.path,
  304. publishEndDate: '',
  305. publishStartDate: '',
  306. tags: this.tags,
  307. title: this.title,
  308. versionId: 0,
  309. versionDate: this.updatedAt
  310. })
  311. this.target = this.cache[0]
  312. if (this.effectivePermissions) {
  313. this.$store.set('page/effectivePermissions', JSON.parse(Buffer.from(this.effectivePermissions, 'base64').toString()))
  314. }
  315. },
  316. methods: {
  317. async loadVersion (versionId) {
  318. this.$store.commit(`loadingStart`, 'history-version-' + versionId)
  319. const resp = await this.$apollo.query({
  320. query: gql`
  321. query ($pageId: Int!, $versionId: Int!) {
  322. pages {
  323. version (pageId: $pageId, versionId: $versionId) {
  324. action
  325. authorId
  326. authorName
  327. content
  328. contentType
  329. createdAt
  330. versionDate
  331. description
  332. editor
  333. isPrivate
  334. isPublished
  335. locale
  336. pageId
  337. path
  338. publishEndDate
  339. publishStartDate
  340. tags
  341. title
  342. versionId
  343. }
  344. }
  345. }
  346. `,
  347. variables: {
  348. versionId,
  349. pageId: this.pageId
  350. }
  351. })
  352. this.$store.commit(`loadingStop`, 'history-version-' + versionId)
  353. const page = _.get(resp, 'data.pages.version', null)
  354. if (page) {
  355. this.cache.push(page)
  356. return page
  357. } else {
  358. return { content: '' }
  359. }
  360. },
  361. viewSource (versionId) {
  362. window.location.assign(`/s/${this.locale}/${this.path}?v=${versionId}`)
  363. },
  364. download (versionId) {
  365. window.location.assign(`/d/${this.locale}/${this.path}?v=${versionId}`)
  366. },
  367. restore (versionId, versionDate) {
  368. this.restoreTarget = {
  369. versionId,
  370. versionDate
  371. }
  372. this.isRestoreConfirmDialogShown = true
  373. },
  374. async restoreConfirm () {
  375. this.restoreLoading = true
  376. this.$store.commit(`loadingStart`, 'history-restore')
  377. try {
  378. const resp = await this.$apollo.mutate({
  379. mutation: gql`
  380. mutation ($pageId: Int!, $versionId: Int!) {
  381. pages {
  382. restore (pageId: $pageId, versionId: $versionId) {
  383. responseResult {
  384. succeeded
  385. errorCode
  386. slug
  387. message
  388. }
  389. }
  390. }
  391. }
  392. `,
  393. variables: {
  394. versionId: this.restoreTarget.versionId,
  395. pageId: this.pageId
  396. }
  397. })
  398. if (_.get(resp, 'data.pages.restore.responseResult.succeeded', false) === true) {
  399. this.$store.commit('showNotification', {
  400. style: 'success',
  401. message: this.$t('history:restore.success'),
  402. icon: 'check'
  403. })
  404. this.isRestoreConfirmDialogShown = false
  405. setTimeout(() => {
  406. window.location.assign(`/${this.locale}/${this.path}`)
  407. }, 1000)
  408. } else {
  409. throw new Error(_.get(resp, 'data.pages.restore.responseResult.message', 'An unexpected error occurred'))
  410. }
  411. } catch (err) {
  412. this.$store.commit('showNotification', {
  413. style: 'red',
  414. message: err.message,
  415. icon: 'alert'
  416. })
  417. }
  418. this.$store.commit(`loadingStop`, 'history-restore')
  419. this.restoreLoading = false
  420. },
  421. branchOff (versionId) {
  422. const pathParts = this.path.split('/')
  423. this.branchOffOpts = {
  424. versionId: versionId,
  425. locale: this.locale,
  426. path: (pathParts.length > 1) ? _.initial(pathParts).join('/') + `/new-page` : `new-page`,
  427. modal: true
  428. }
  429. },
  430. branchOffHandle ({ locale, path }) {
  431. window.location.assign(`/e/${locale}/${path}?from=${this.pageId},${this.branchOffOpts.versionId}`)
  432. },
  433. toggleViewMode () {
  434. this.viewMode = (this.viewMode === 'line-by-line') ? 'side-by-side' : 'line-by-line'
  435. },
  436. goLive () {
  437. window.location.assign(`/${this.path}`)
  438. },
  439. setDiffSource (versionId) {
  440. this.diffSource = versionId
  441. },
  442. setDiffTarget (versionId) {
  443. this.diffTarget = versionId
  444. },
  445. loadMore () {
  446. this.offsetPage++
  447. this.$apollo.queries.trail.fetchMore({
  448. variables: {
  449. id: this.pageId,
  450. offsetPage: this.offsetPage,
  451. offsetSize: this.$vuetify.breakpoint.mdAndUp ? 25 : 5
  452. },
  453. updateQuery: (previousResult, { fetchMoreResult }) => {
  454. return {
  455. pages: {
  456. history: {
  457. total: previousResult.pages.history.total,
  458. trail: [...previousResult.pages.history.trail, ...fetchMoreResult.pages.history.trail],
  459. __typename: previousResult.pages.history.__typename
  460. },
  461. __typename: previousResult.pages.__typename
  462. }
  463. }
  464. }
  465. })
  466. },
  467. trailColor (actionType) {
  468. switch (actionType) {
  469. case 'edit':
  470. return 'primary'
  471. case 'move':
  472. return 'purple'
  473. case 'initial':
  474. return 'teal'
  475. case 'live':
  476. return 'orange'
  477. default:
  478. return 'grey'
  479. }
  480. },
  481. trailIcon (actionType) {
  482. switch (actionType) {
  483. case 'edit':
  484. return '' // 'mdi-pencil'
  485. case 'move':
  486. return 'mdi-forward'
  487. case 'initial':
  488. return 'mdi-plus'
  489. case 'live':
  490. return 'mdi-atom-variant'
  491. default:
  492. return 'mdi-alert'
  493. }
  494. },
  495. trailBgColor (actionType) {
  496. switch (actionType) {
  497. case 'move':
  498. return this.$vuetify.theme.dark ? 'purple' : 'purple lighten-5'
  499. case 'initial':
  500. return this.$vuetify.theme.dark ? 'teal darken-3' : 'teal lighten-5'
  501. case 'live':
  502. return this.$vuetify.theme.dark ? 'orange darken-3' : 'orange lighten-5'
  503. default:
  504. return this.$vuetify.theme.dark ? 'grey darken-3' : 'grey lighten-4'
  505. }
  506. }
  507. },
  508. apollo: {
  509. trail: {
  510. query: gql`
  511. query($id: Int!, $offsetPage: Int, $offsetSize: Int) {
  512. pages {
  513. history(id:$id, offsetPage:$offsetPage, offsetSize:$offsetSize) {
  514. trail {
  515. versionId
  516. authorId
  517. authorName
  518. actionType
  519. valueBefore
  520. valueAfter
  521. versionDate
  522. }
  523. total
  524. }
  525. }
  526. }
  527. `,
  528. variables () {
  529. return {
  530. id: this.pageId,
  531. offsetPage: 0,
  532. offsetSize: this.$vuetify.breakpoint.mdAndUp ? 25 : 5
  533. }
  534. },
  535. manual: true,
  536. result ({ data, loading, networkStatus }) {
  537. this.total = data.pages.history.total
  538. this.trail = data.pages.history.trail
  539. },
  540. watchLoading (isLoading) {
  541. this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'history-trail-refresh')
  542. }
  543. }
  544. }
  545. }
  546. </script>
  547. <style lang='scss'>
  548. .history {
  549. &-promptmenu {
  550. border-top: 5px solid mc('blue', '700');
  551. }
  552. .d2h-file-wrapper {
  553. border: 1px solid #EEE;
  554. border-left: none;
  555. }
  556. .d2h-file-header {
  557. display: none;
  558. }
  559. }
  560. </style>