history.vue 17 KB

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