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 comment-list |
|||
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 comment |
|||
include composer |
|||
+renderComment(comment) |
|||
+renderComment(comment, appletOptions || { }) |
@ -1,4 +1,4 @@ |
|||
include ../../components/library |
|||
include comment-list |
|||
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