You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

547 lines
15 KiB

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