history.vue 18 KB

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