comments.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. <template lang="pug">
  2. div(v-intersect.once='onIntersect')
  3. v-textarea#discussion-new(
  4. outlined
  5. flat
  6. :placeholder='$t(`common:comments.newPlaceholder`)'
  7. auto-grow
  8. dense
  9. rows='3'
  10. hide-details
  11. v-model='newcomment'
  12. color='blue-grey darken-2'
  13. :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
  14. v-if='permissions.write'
  15. :aria-label='$t(`common:comments.fieldContent`)'
  16. )
  17. v-row.mt-2(dense, v-if='!isAuthenticated && permissions.write')
  18. v-col(cols='12', lg='6')
  19. v-text-field(
  20. outlined
  21. color='blue-grey darken-2'
  22. :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
  23. :placeholder='$t(`common:comments.fieldName`)'
  24. hide-details
  25. dense
  26. autocomplete='name'
  27. v-model='guestName'
  28. :aria-label='$t(`common:comments.fieldName`)'
  29. )
  30. v-col(cols='12', lg='6')
  31. v-text-field(
  32. outlined
  33. color='blue-grey darken-2'
  34. :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
  35. :placeholder='$t(`common:comments.fieldEmail`)'
  36. hide-details
  37. type='email'
  38. dense
  39. autocomplete='email'
  40. v-model='guestEmail'
  41. :aria-label='$t(`common:comments.fieldEmail`)'
  42. )
  43. .d-flex.align-center.pt-3(v-if='permissions.write')
  44. v-icon.mr-1(color='blue-grey') mdi-language-markdown-outline
  45. .caption.blue-grey--text {{$t('common:comments.markdownFormat')}}
  46. v-spacer
  47. .caption.mr-3(v-if='isAuthenticated')
  48. i18next(tag='span', path='common:comments.postingAs')
  49. strong(place='name') {{userDisplayName}}
  50. v-btn(
  51. dark
  52. color='blue-grey darken-2'
  53. @click='postComment'
  54. depressed
  55. :aria-label='$t(`common:comments.postComment`)'
  56. )
  57. v-icon(left) mdi-comment
  58. span.text-none {{$t('common:comments.postComment')}}
  59. v-divider.mt-3(v-if='permissions.write')
  60. .pa-5.d-flex.align-center.justify-center(v-if='isLoading && !hasLoadedOnce')
  61. v-progress-circular(
  62. indeterminate
  63. size='20'
  64. width='1'
  65. color='blue-grey'
  66. )
  67. .caption.blue-grey--text.pl-3: em {{$t('common:comments.loading')}}
  68. v-timeline(
  69. dense
  70. v-else-if='comments && comments.length > 0'
  71. )
  72. v-timeline-item.comments-post(
  73. color='pink darken-4'
  74. large
  75. v-for='cm of comments'
  76. :key='`comment-` + cm.id'
  77. :id='`comment-post-id-` + cm.id'
  78. )
  79. template(v-slot:icon)
  80. v-avatar(color='blue-grey')
  81. //- v-img(src='http://i.pravatar.cc/64')
  82. span.white--text.title {{cm.initials}}
  83. v-card.elevation-1
  84. v-card-text
  85. .comments-post-actions(v-if='permissions.manage && !isBusy && commentEditId === 0')
  86. v-icon.mr-3(small, @click='editComment(cm)') mdi-pencil
  87. v-icon(small, @click='deleteCommentConfirm(cm)') mdi-delete
  88. .comments-post-name.caption: strong {{cm.authorName}}
  89. .comments-post-date.overline.grey--text {{cm.createdAt | moment('from') }} #[em(v-if='cm.createdAt !== cm.updatedAt') - {{$t('common:comments.modified', { reldate: $options.filters.moment(cm.updatedAt, 'from') })}}]
  90. .comments-post-content.mt-3(v-if='commentEditId !== cm.id', v-html='cm.render')
  91. .comments-post-editcontent.mt-3(v-else)
  92. v-textarea(
  93. outlined
  94. flat
  95. auto-grow
  96. dense
  97. rows='3'
  98. hide-details
  99. v-model='commentEditContent'
  100. color='blue-grey darken-2'
  101. :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
  102. )
  103. .d-flex.align-center.pt-3
  104. v-spacer
  105. v-btn.mr-3(
  106. dark
  107. color='blue-grey darken-2'
  108. @click='editCommentCancel'
  109. outlined
  110. )
  111. v-icon(left) mdi-close
  112. span.text-none {{$t('common:actions.cancel')}}
  113. v-btn(
  114. dark
  115. color='blue-grey darken-2'
  116. @click='updateComment'
  117. depressed
  118. )
  119. v-icon(left) mdi-comment
  120. span.text-none {{$t('common:comments.updateComment')}}
  121. .pt-5.text-center.body-2.blue-grey--text(v-else-if='permissions.write') {{$t('common:comments.beFirst')}}
  122. .text-center.body-2.blue-grey--text(v-else) {{$t('common:comments.none')}}
  123. v-dialog(v-model='deleteCommentDialogShown', max-width='500')
  124. v-card
  125. .dialog-header.is-red {{$t('common:comments.deleteConfirmTitle')}}
  126. v-card-text.pt-5
  127. span {{$t('common:comments.deleteWarn')}}
  128. .caption: strong {{$t('common:comments.deletePermanentWarn')}}
  129. v-card-chin
  130. v-spacer
  131. v-btn(text, @click='deleteCommentDialogShown = false') {{$t('common:actions.cancel')}}
  132. v-btn(color='red', dark, @click='deleteComment') {{$t('common:actions.delete')}}
  133. </template>
  134. <script>
  135. import gql from 'graphql-tag'
  136. import { get } from 'vuex-pathify'
  137. import validate from 'validate.js'
  138. import _ from 'lodash'
  139. export default {
  140. data () {
  141. return {
  142. newcomment: '',
  143. isLoading: true,
  144. hasLoadedOnce: false,
  145. comments: [],
  146. guestName: '',
  147. guestEmail: '',
  148. commentToDelete: {},
  149. commentEditId: 0,
  150. commentEditContent: null,
  151. deleteCommentDialogShown: false,
  152. isBusy: false,
  153. scrollOpts: {
  154. duration: 1500,
  155. offset: 0,
  156. easing: 'easeInOutCubic'
  157. }
  158. }
  159. },
  160. computed: {
  161. pageId: get('page/id'),
  162. permissions: get('page/effectivePermissions@comments'),
  163. isAuthenticated: get('user/authenticated'),
  164. userDisplayName: get('user/name')
  165. },
  166. methods: {
  167. onIntersect (entries, observer, isIntersecting) {
  168. if (isIntersecting) {
  169. this.fetch(true)
  170. }
  171. },
  172. async fetch (silent = false) {
  173. this.isLoading = true
  174. try {
  175. const results = await this.$apollo.query({
  176. query: gql`
  177. query ($locale: String!, $path: String!) {
  178. comments {
  179. list(locale: $locale, path: $path) {
  180. id
  181. render
  182. authorName
  183. createdAt
  184. updatedAt
  185. }
  186. }
  187. }
  188. `,
  189. variables: {
  190. locale: this.$store.get('page/locale'),
  191. path: this.$store.get('page/path')
  192. },
  193. fetchPolicy: 'network-only'
  194. })
  195. this.comments = _.get(results, 'data.comments.list', []).map(c => {
  196. const nameParts = c.authorName.toUpperCase().split(' ')
  197. let initials = _.head(nameParts).charAt(0)
  198. if (nameParts.length > 1) {
  199. initials += _.last(nameParts).charAt(0)
  200. }
  201. c.initials = initials
  202. return c
  203. })
  204. } catch (err) {
  205. console.warn(err)
  206. if (!silent) {
  207. this.$store.commit('showNotification', {
  208. style: 'red',
  209. message: err.message,
  210. icon: 'alert'
  211. })
  212. }
  213. }
  214. this.isLoading = false
  215. this.hasLoadedOnce = true
  216. },
  217. /**
  218. * Post New Comment
  219. */
  220. async postComment () {
  221. let rules = {
  222. comment: {
  223. presence: {
  224. allowEmpty: false
  225. },
  226. length: {
  227. minimum: 2
  228. }
  229. }
  230. }
  231. if (!this.isAuthenticated && this.permissions.write) {
  232. rules.name = {
  233. presence: {
  234. allowEmpty: false
  235. },
  236. length: {
  237. minimum: 2,
  238. maximum: 255
  239. }
  240. }
  241. rules.email = {
  242. presence: {
  243. allowEmpty: false
  244. },
  245. email: true
  246. }
  247. }
  248. const validationResults = validate({
  249. comment: this.newcomment,
  250. name: this.guestName,
  251. email: this.guestEmail
  252. }, rules, { format: 'flat' })
  253. if (validationResults) {
  254. this.$store.commit('showNotification', {
  255. style: 'red',
  256. message: validationResults[0],
  257. icon: 'alert'
  258. })
  259. return
  260. }
  261. try {
  262. const resp = await this.$apollo.mutate({
  263. mutation: gql`
  264. mutation (
  265. $pageId: Int!
  266. $replyTo: Int
  267. $content: String!
  268. $guestName: String
  269. $guestEmail: String
  270. ) {
  271. comments {
  272. create (
  273. pageId: $pageId
  274. replyTo: $replyTo
  275. content: $content
  276. guestName: $guestName
  277. guestEmail: $guestEmail
  278. ) {
  279. responseResult {
  280. succeeded
  281. errorCode
  282. slug
  283. message
  284. }
  285. id
  286. }
  287. }
  288. }
  289. `,
  290. variables: {
  291. pageId: this.pageId,
  292. replyTo: 0,
  293. content: this.newcomment,
  294. guestName: this.guestName,
  295. guestEmail: this.guestEmail
  296. }
  297. })
  298. if (_.get(resp, 'data.comments.create.responseResult.succeeded', false)) {
  299. this.$store.commit('showNotification', {
  300. style: 'success',
  301. message: this.$t('common:comments.postSuccess'),
  302. icon: 'check'
  303. })
  304. this.newcomment = ''
  305. await this.fetch()
  306. this.$nextTick(() => {
  307. this.$vuetify.goTo(`#comment-post-id-${_.get(resp, 'data.comments.create.id', 0)}`, this.scrollOpts)
  308. })
  309. } else {
  310. throw new Error(_.get(resp, 'data.comments.create.responseResult.message', 'An unexpected error occurred.'))
  311. }
  312. } catch (err) {
  313. this.$store.commit('showNotification', {
  314. style: 'red',
  315. message: err.message,
  316. icon: 'alert'
  317. })
  318. }
  319. },
  320. /**
  321. * Show Comment Editing Form
  322. */
  323. async editComment (cm) {
  324. this.$store.commit(`loadingStart`, 'comments-edit')
  325. this.isBusy = true
  326. try {
  327. const results = await this.$apollo.query({
  328. query: gql`
  329. query ($id: Int!) {
  330. comments {
  331. single(id: $id) {
  332. content
  333. }
  334. }
  335. }
  336. `,
  337. variables: {
  338. id: cm.id
  339. },
  340. fetchPolicy: 'network-only'
  341. })
  342. this.commentEditContent = _.get(results, 'data.comments.single.content', null)
  343. if (this.commentEditContent === null) {
  344. throw new Error('Failed to load comment content.')
  345. }
  346. } catch (err) {
  347. console.warn(err)
  348. this.$store.commit('showNotification', {
  349. style: 'red',
  350. message: err.message,
  351. icon: 'alert'
  352. })
  353. }
  354. this.commentEditId = cm.id
  355. this.isBusy = false
  356. this.$store.commit(`loadingStop`, 'comments-edit')
  357. },
  358. /**
  359. * Cancel Comment Edit
  360. */
  361. editCommentCancel () {
  362. this.commentEditId = 0
  363. this.commentEditContent = null
  364. },
  365. /**
  366. * Update Comment with new content
  367. */
  368. async updateComment () {
  369. this.$store.commit(`loadingStart`, 'comments-edit')
  370. this.isBusy = true
  371. try {
  372. if (this.commentEditContent.length < 2) {
  373. throw new Error(this.$t('common:comments.contentMissingError'))
  374. }
  375. const resp = await this.$apollo.mutate({
  376. mutation: gql`
  377. mutation (
  378. $id: Int!
  379. $content: String!
  380. ) {
  381. comments {
  382. update (
  383. id: $id,
  384. content: $content
  385. ) {
  386. responseResult {
  387. succeeded
  388. errorCode
  389. slug
  390. message
  391. }
  392. render
  393. }
  394. }
  395. }
  396. `,
  397. variables: {
  398. id: this.commentEditId,
  399. content: this.commentEditContent
  400. }
  401. })
  402. if (_.get(resp, 'data.comments.update.responseResult.succeeded', false)) {
  403. this.$store.commit('showNotification', {
  404. style: 'success',
  405. message: this.$t('common:comments.updateSuccess'),
  406. icon: 'check'
  407. })
  408. const cm = _.find(this.comments, ['id', this.commentEditId])
  409. cm.render = _.get(resp, 'data.comments.update.render', '-- Failed to load updated comment --')
  410. cm.updatedAt = (new Date()).toISOString()
  411. this.editCommentCancel()
  412. } else {
  413. throw new Error(_.get(resp, 'data.comments.delete.responseResult.message', 'An unexpected error occurred.'))
  414. }
  415. } catch (err) {
  416. console.warn(err)
  417. this.$store.commit('showNotification', {
  418. style: 'red',
  419. message: err.message,
  420. icon: 'alert'
  421. })
  422. }
  423. this.isBusy = false
  424. this.$store.commit(`loadingStop`, 'comments-edit')
  425. },
  426. /**
  427. * Show Delete Comment Confirmation Dialog
  428. */
  429. deleteCommentConfirm (cm) {
  430. this.commentToDelete = cm
  431. this.deleteCommentDialogShown = true
  432. },
  433. /**
  434. * Delete Comment
  435. */
  436. async deleteComment () {
  437. this.$store.commit(`loadingStart`, 'comments-delete')
  438. this.isBusy = true
  439. this.deleteCommentDialogShown = false
  440. try {
  441. const resp = await this.$apollo.mutate({
  442. mutation: gql`
  443. mutation (
  444. $id: Int!
  445. ) {
  446. comments {
  447. delete (
  448. id: $id
  449. ) {
  450. responseResult {
  451. succeeded
  452. errorCode
  453. slug
  454. message
  455. }
  456. }
  457. }
  458. }
  459. `,
  460. variables: {
  461. id: this.commentToDelete.id
  462. }
  463. })
  464. if (_.get(resp, 'data.comments.delete.responseResult.succeeded', false)) {
  465. this.$store.commit('showNotification', {
  466. style: 'success',
  467. message: this.$t('common:comments.deleteSuccess'),
  468. icon: 'check'
  469. })
  470. this.comments = _.reject(this.comments, ['id', this.commentToDelete.id])
  471. } else {
  472. throw new Error(_.get(resp, 'data.comments.delete.responseResult.message', 'An unexpected error occurred.'))
  473. }
  474. } catch (err) {
  475. this.$store.commit('showNotification', {
  476. style: 'red',
  477. message: err.message,
  478. icon: 'alert'
  479. })
  480. }
  481. this.isBusy = false
  482. this.$store.commit(`loadingStop`, 'comments-delete')
  483. }
  484. }
  485. }
  486. </script>
  487. <style lang="scss">
  488. .comments-post {
  489. position: relative;
  490. &:hover {
  491. .comments-post-actions {
  492. opacity: 1;
  493. }
  494. }
  495. &-actions {
  496. position: absolute;
  497. top: 16px;
  498. right: 16px;
  499. opacity: 0;
  500. transition: opacity .4s ease;
  501. }
  502. &-content {
  503. > p:first-child {
  504. padding-top: 0;
  505. }
  506. p {
  507. padding-top: 1rem;
  508. margin-bottom: 0;
  509. }
  510. img {
  511. max-width: 100%;
  512. border-radius: 5px;
  513. }
  514. code {
  515. background-color: rgba(mc('pink', '500'), .1);
  516. box-shadow: none;
  517. }
  518. pre > code {
  519. margin-top: 1rem;
  520. padding: 12px;
  521. background-color: #111;
  522. box-shadow: none;
  523. border-radius: 5px;
  524. width: 100%;
  525. color: #FFF;
  526. font-weight: 400;
  527. font-size: .85rem;
  528. font-family: Roboto Mono, monospace;
  529. }
  530. }
  531. }
  532. </style>