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.

549 lines
15 KiB

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