history.vue 18 KB

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