18 changed files with 599 additions and 208 deletions
@ -0,0 +1,157 @@ |
|||||
|
// comment.js
|
||||
|
// Copyright (C) 2021 Digital Telepresence, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
const express = require('express'); |
||||
|
const numeral = require('numeral'); |
||||
|
|
||||
|
const { SiteController, SiteError } = require('../../lib/site-lib'); |
||||
|
|
||||
|
class CommentController extends SiteController { |
||||
|
|
||||
|
constructor (dtp) { |
||||
|
super(dtp, module.exports); |
||||
|
} |
||||
|
|
||||
|
async start ( ) { |
||||
|
const { dtp } = this; |
||||
|
const { limiter: limiterService, session: sessionService } = dtp.services; |
||||
|
|
||||
|
const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); |
||||
|
|
||||
|
const router = express.Router(); |
||||
|
dtp.app.use('/comment', router); |
||||
|
|
||||
|
router.use(async (req, res, next) => { |
||||
|
res.locals.currentView = module.exports.slug; |
||||
|
return next(); |
||||
|
}); |
||||
|
|
||||
|
router.param('commentId', this.populateCommentId.bind(this)); |
||||
|
|
||||
|
router.post('/:commentId/vote', authRequired, this.postVote.bind(this)); |
||||
|
|
||||
|
router.get('/:commentId/replies', this.getCommentReplies.bind(this)); |
||||
|
|
||||
|
router.delete('/:commentId', |
||||
|
authRequired, |
||||
|
limiterService.createMiddleware(limiterService.config.comment.deleteComment), |
||||
|
this.deleteComment.bind(this), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async populateCommentId (req, res, next, commentId) { |
||||
|
const { comment: commentService } = this.dtp.services; |
||||
|
try { |
||||
|
res.locals.comment = await commentService.getById(commentId); |
||||
|
if (!res.locals.comment) { |
||||
|
return next(new SiteError(404, 'Comment not found')); |
||||
|
} |
||||
|
res.locals.post = res.locals.comment.resource; |
||||
|
return next(); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to populate commentId', { commentId, error }); |
||||
|
return next(error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async postVote (req, res) { |
||||
|
const { contentVote: contentVoteService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('comment-vote'); |
||||
|
const { message, stats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote); |
||||
|
displayList.setTextContent( |
||||
|
`button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`, |
||||
|
numeral(stats.upvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), |
||||
|
); |
||||
|
displayList.setTextContent( |
||||
|
`button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`, |
||||
|
numeral(stats.downvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), |
||||
|
); |
||||
|
displayList.showNotification(message, 'success', 'bottom-center', 3000); |
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to process comment vote', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async getCommentReplies (req, res) { |
||||
|
const { comment: commentService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('get-replies'); |
||||
|
|
||||
|
if (req.query.buttonId) { |
||||
|
displayList.removeElement(`li.dtp-load-more[data-button-id="${req.query.buttonId}"]`); |
||||
|
} |
||||
|
|
||||
|
Object.assign(res.locals, req.app.locals); |
||||
|
|
||||
|
res.locals.countPerPage = parseInt(req.query.cpp || "20", 10); |
||||
|
if (res.locals.countPerPage < 1) { |
||||
|
res.locals.countPerPage = 1; |
||||
|
} |
||||
|
if (res.locals.countPerPage > 20) { |
||||
|
res.locals.countPerPage = 20; |
||||
|
} |
||||
|
|
||||
|
res.locals.pagination = this.getPaginationParameters(req, res.locals.countPerPage); |
||||
|
res.locals.comments = await commentService.getReplies(res.locals.comment, res.locals.pagination); |
||||
|
|
||||
|
const html = await commentService.renderTemplate('replyList', res.locals); |
||||
|
|
||||
|
const replyList = `ul.dtp-reply-list[data-comment-id="${res.locals.comment._id}"]`; |
||||
|
displayList.addElement(replyList, 'beforeEnd', html); |
||||
|
|
||||
|
const replyListContainer = `.dtp-reply-list-container[data-comment-id="${res.locals.comment._id}"]`; |
||||
|
displayList.removeAttribute(replyListContainer, 'hidden'); |
||||
|
|
||||
|
if (Array.isArray(res.locals.comments) && (res.locals.comments.length > 0)) { |
||||
|
displayList.removeElement(`p#empty-comments-label[data-comment-id="${res.locals.comment._id}"]`); |
||||
|
} |
||||
|
|
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to display comment replies', { error }); |
||||
|
res.status(error.statusCode || 500).json({ success: false, message: error.message }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async deleteComment (req, res) { |
||||
|
const { comment: commentService } = this.dtp.services; |
||||
|
try { |
||||
|
const displayList = this.createDisplayList('add-recipient'); |
||||
|
|
||||
|
await commentService.remove(res.locals.comment, 'removed'); |
||||
|
|
||||
|
let selector = `article[data-comment-id="${res.locals.comment._id}"] .comment-content`; |
||||
|
displayList.setTextContent(selector, 'Comment removed'); |
||||
|
|
||||
|
displayList.showNotification( |
||||
|
'Comment removed successfully', |
||||
|
'success', |
||||
|
'bottom-center', |
||||
|
5000, |
||||
|
); |
||||
|
|
||||
|
res.status(200).json({ success: true, displayList }); |
||||
|
} catch (error) { |
||||
|
this.log.error('failed to remove comment', { error }); |
||||
|
return res.status(error.statusCode || 500).json({ |
||||
|
success: false, |
||||
|
message: error.message |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports = { |
||||
|
slug: 'comment', |
||||
|
name: 'comment', |
||||
|
create: async (dtp) => { return new CommentController(dtp); }, |
||||
|
}; |
@ -0,0 +1,11 @@ |
|||||
|
extends ../layouts/main |
||||
|
block content |
||||
|
|
||||
|
include ../comment/components/section |
||||
|
include components/announcement |
||||
|
|
||||
|
section.uk-section.uk-section-default.uk-section-small |
||||
|
.uk-container |
||||
|
+renderAnnouncement(announcement) |
||||
|
|
||||
|
+renderCommentSection({ name: `announcement-${announcement._id}`, rootUrl: `/announcement/${announcement._id}/comment` }) |
@ -1,4 +1,4 @@ |
|||||
include ../../components/library |
include ../../components/library |
||||
include comment-list |
include comment-list |
||||
include composer |
include composer |
||||
+renderCommentList(comments, { rootUrl: `/post/${post.slug}/comment`, countPerPage }) |
+renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/post/${post.slug}/comment`, countPerPage }) |
@ -1,4 +1,4 @@ |
|||||
include ../../components/library |
include ../../components/library |
||||
include comment |
include comment |
||||
include composer |
include composer |
||||
+renderComment(comment) |
+renderComment(comment, appletOptions || { }) |
@ -1,4 +1,4 @@ |
|||||
include ../../components/library |
include ../../components/library |
||||
include comment-list |
include comment-list |
||||
include composer |
include composer |
||||
+renderCommentList(comments, { rootUrl: `/comment/${comment._id}/replies`, countPerPage }) |
+renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/comment/${comment._id}/replies`, countPerPage }) |
@ -0,0 +1,38 @@ |
|||||
|
include composer |
||||
|
include comment-list |
||||
|
|
||||
|
mixin renderCommentSection (options = { }) |
||||
|
section |
||||
|
.uk-container |
||||
|
if user && user.permissions.canComment |
||||
|
- |
||||
|
const composerOptions = Object.assign({ }, options); |
||||
|
composerOptions.name = `${options.name}-composer`; |
||||
|
.content-block(dtp-comments= composerOptions.name, data-root-url= options.rootUrl) |
||||
|
.uk-margin |
||||
|
+renderSectionTitle('Add a comment') |
||||
|
.uk-margin-small |
||||
|
+renderCommentComposer(composerOptions) |
||||
|
|
||||
|
if featuredComment |
||||
|
.content-block(dtp-comments= `${options.name}-feature`, data-root-url= options.rootUrl) |
||||
|
#featured-comment.uk-margin-large |
||||
|
.uk-margin |
||||
|
+renderSectionTitle('Linked Comment') |
||||
|
- |
||||
|
const featureOptions = Object.assign({ }, options); |
||||
|
featureOptions.name = `${options.name}-feature`; |
||||
|
+renderComment(featuredComment, featureOptions) |
||||
|
|
||||
|
.content-block(dtp-comments= options.name, data-root-url= options.rootUrl) |
||||
|
+renderSectionTitle('Comments') |
||||
|
|
||||
|
if Array.isArray(comments) && (comments.length > 0) |
||||
|
ul#post-comment-list.uk-list.uk-list-divider.uk-list-large |
||||
|
+renderCommentList(comments, Object.assign({ |
||||
|
countPerPage: countPerPage || 10, |
||||
|
rootUrl: options.rootUrl, |
||||
|
}, options)) |
||||
|
else |
||||
|
ul#post-comment-list.uk-list.uk-list-divider.uk-list-large |
||||
|
div There are no comments at this time. Please check back later. |
@ -0,0 +1,244 @@ |
|||||
|
// site-comments.js
|
||||
|
// Copyright (C) 2022 DTP Technologies, LLC
|
||||
|
// License: Apache-2.0
|
||||
|
|
||||
|
'use strict'; |
||||
|
|
||||
|
import DtpLog from 'dtp/dtp-log.js'; |
||||
|
|
||||
|
import UIkit from 'uikit'; |
||||
|
|
||||
|
import * as picmo from 'picmo'; |
||||
|
|
||||
|
export default class SiteComments { |
||||
|
|
||||
|
constructor (app, rootElement) { |
||||
|
this.app = app; |
||||
|
this.log = new DtpLog({ name: 'Site Comments', slug: 'comments' }); |
||||
|
|
||||
|
this.ui = { |
||||
|
input: rootElement.querySelector('#comment-content'), |
||||
|
emojiPicker: rootElement.querySelector('#comment-emoji-picker'), |
||||
|
characterCount: rootElement.querySelector('.comment-character-count'), |
||||
|
}; |
||||
|
|
||||
|
if (this.ui.emojiPicker) { |
||||
|
this.ui.emojiPickerUI = this.ui.emojiPicker.querySelector('.comment-emoji-picker'); |
||||
|
this.ui.picmo = picmo.createPicker({ |
||||
|
emojisPerRow: 7, |
||||
|
rootElement: this.ui.emojiPickerUI, |
||||
|
theme: picmo.darkTheme, |
||||
|
}); |
||||
|
|
||||
|
this.ui.picmo.addEventListener('emoji:select', this.onEmojiSelected.bind(this)); |
||||
|
|
||||
|
this.ui.emojiPickerDrop = UIkit.drop(this.ui.emojiPicker); |
||||
|
UIkit.util.on(this.ui.emojiPicker, 'show', ( ) => { |
||||
|
this.log.info('SiteComments', 'showing emoji picker'); |
||||
|
this.ui.picmo.reset(); |
||||
|
}); |
||||
|
} else { |
||||
|
UIkit.modal.alert('Comment section without an emoji picker defined'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async onCommentInput (event) { |
||||
|
this.ui.characterCount.textContent = numeral(event.target.value.length).format('0,0'); |
||||
|
} |
||||
|
|
||||
|
async showReportCommentForm (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const resourceType = event.currentTarget.getAttribute('data-resource-type'); |
||||
|
const resourceId = event.currentTarget.getAttribute('data-resource-id'); |
||||
|
const commentId = event.currentTarget.getAttribute('data-comment-id'); |
||||
|
|
||||
|
this.closeCommentDropdownMenu(commentId); |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch('/content-report/comment/form', { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify({ |
||||
|
resourceType, resourceId, commentId |
||||
|
}), |
||||
|
}); |
||||
|
if (!response.ok) { |
||||
|
throw new Error('failed to load report form'); |
||||
|
} |
||||
|
const html = await response.text(); |
||||
|
this.currentDialog = UIkit.modal.dialog(html); |
||||
|
} catch (error) { |
||||
|
this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); |
||||
|
UIkit.modal.alert(`Failed to report comment: ${error.message}`); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async deleteComment (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
const commentId = (event.currentTarget || event.target).getAttribute('data-comment-id'); |
||||
|
try { |
||||
|
const response = fetch(`/comment/${commentId}`, { method: 'DELETE' }); |
||||
|
if (!response.ok) { |
||||
|
throw new Error('Server error'); |
||||
|
} |
||||
|
this.app.processResponse(response); |
||||
|
} catch (error) { |
||||
|
UIkit.modal.alert(`Failed to delete comment: ${error.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async blockCommentAuthor (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const resourceType = event.currentTarget.getAttribute('data-resource-type'); |
||||
|
const resourceId = event.currentTarget.getAttribute('data-resource-id'); |
||||
|
const commentId = event.currentTarget.getAttribute('data-comment-id'); |
||||
|
const actionUrl = this.getCommentActionUrl(resourceType, resourceId, commentId, 'block-author'); |
||||
|
|
||||
|
this.closeCommentDropdownMenu(commentId); |
||||
|
|
||||
|
try { |
||||
|
this.log.info('blockCommentAuthor', 'blocking comment author', { resourceType, resourceId, commentId }); |
||||
|
const response = await fetch(actionUrl, { method: 'POST'}); |
||||
|
await this.app.processResponse(response); |
||||
|
|
||||
|
} catch (error) { |
||||
|
this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); |
||||
|
UIkit.modal.alert(`Failed to block comment author: ${error.message}`); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
closeCommentDropdownMenu (commentId) { |
||||
|
const dropdown = document.querySelector(`[data-comment-id="${commentId}"][uk-dropdown]`); |
||||
|
UIkit.dropdown(dropdown).hide(false); |
||||
|
} |
||||
|
|
||||
|
getCommentActionUrl (resourceType, resourceId, commentId, action) { |
||||
|
switch (resourceType) { |
||||
|
case 'Newsletter': |
||||
|
return `/newsletter/${resourceId}/comment/${commentId}/${action}`; |
||||
|
case 'Page': |
||||
|
return `/page/${resourceId}/comment/${commentId}/${action}`; |
||||
|
case 'Post': |
||||
|
return `/post/${resourceId}/comment/${commentId}/${action}`; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
throw new Error('Invalid resource type for comment operation'); |
||||
|
} |
||||
|
|
||||
|
async submitCommentVote (event) { |
||||
|
const target = (event.currentTarget || event.target); |
||||
|
const commentId = target.getAttribute('data-comment-id'); |
||||
|
const vote = target.getAttribute('data-vote'); |
||||
|
try { |
||||
|
const response = await fetch(`/comment/${commentId}/vote`, { |
||||
|
method: 'POST', |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json', |
||||
|
}, |
||||
|
body: JSON.stringify({ vote }), |
||||
|
}); |
||||
|
await this.app.processResponse(response); |
||||
|
} catch (error) { |
||||
|
UIkit.modal.alert(`Failed to submit vote: ${error.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async openReplies (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const target = event.currentTarget || event.target; |
||||
|
const commentId = target.getAttribute('data-comment-id'); |
||||
|
|
||||
|
const container = document.querySelector(`.dtp-reply-list-container[data-comment-id="${commentId}"]`); |
||||
|
const replyList = document.querySelector(`ul.dtp-reply-list[data-comment-id="${commentId}"]`); |
||||
|
|
||||
|
const isOpen = !container.hasAttribute('hidden'); |
||||
|
if (isOpen) { |
||||
|
container.setAttribute('hidden', ''); |
||||
|
while (replyList.firstChild) { |
||||
|
replyList.removeChild(replyList.firstChild); |
||||
|
} |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch(`/comment/${commentId}/replies`); |
||||
|
this.app.processResponse(response); |
||||
|
} catch (error) { |
||||
|
UIkit.modal.alert(`Failed to load replies: ${error.message}`); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async openReplyComposer (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const target = event.currentTarget || event.target; |
||||
|
const commentId = target.getAttribute('data-comment-id'); |
||||
|
const composer = document.querySelector(`.dtp-reply-composer[data-comment-id="${commentId}"]`); |
||||
|
composer.toggleAttribute('hidden'); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async loadMoreComments (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
|
||||
|
const target = event.currentTarget || event.target; |
||||
|
|
||||
|
const buttonId = target.getAttribute('data-button-id'); |
||||
|
const rootUrl = target.getAttribute('data-root-url'); |
||||
|
const nextPage = target.getAttribute('data-next-page'); |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch(`${rootUrl}?p=${nextPage}&buttonId=${buttonId}`); |
||||
|
await this.app.processResponse(response); |
||||
|
} catch (error) { |
||||
|
UIkit.modal.alert(`Failed to load more comments: ${error.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async onEmojiSelected (event) { |
||||
|
this.ui.emojiPickerDrop.hide(false); |
||||
|
return this.insertContentAtCursor(event.emoji); |
||||
|
} |
||||
|
|
||||
|
async insertContentAtCursor (content) { |
||||
|
this.ui.input.focus(); |
||||
|
|
||||
|
if (document.selection) { |
||||
|
let sel = document.selection.createRange(); |
||||
|
sel.text = content; |
||||
|
} else if (this.ui.input.selectionStart || (this.ui.input.selectionStart === 0)) { |
||||
|
let startPos = this.ui.input.selectionStart; |
||||
|
let endPos = this.ui.input.selectionEnd; |
||||
|
|
||||
|
let oldLength = this.ui.input.value.length; |
||||
|
this.ui.input.value = |
||||
|
this.ui.input.value.substring(0, startPos) + |
||||
|
content + |
||||
|
this.ui.input.value.substring(endPos, this.ui.input.value.length); |
||||
|
|
||||
|
this.ui.input.selectionStart = startPos + (this.ui.input.value.length - oldLength); |
||||
|
this.ui.input.selectionEnd = this.ui.input.selectionStart; |
||||
|
} else { |
||||
|
this.ui.input.value += content; |
||||
|
} |
||||
|
} |
||||
|
} |
Loading…
Reference in new issue