Browse Source

Comments update to make them more resource agnostic and usable

develop^2
Rob Colbert 2 years ago
parent
commit
8e872cd688
  1. 23
      app/controllers/announcement.js
  2. 157
      app/controllers/comment.js
  3. 7
      app/models/comment.js
  4. 5
      app/models/lib/resource-stats.js
  5. 6
      app/models/resource-view.js
  6. 46
      app/services/comment.js
  7. 16
      app/views/announcement/components/announcement.pug
  8. 11
      app/views/announcement/view.pug
  9. 2
      app/views/comment/components/comment-list-standalone.pug
  10. 9
      app/views/comment/components/comment-list.pug
  11. 2
      app/views/comment/components/comment-standalone.pug
  12. 25
      app/views/comment/components/comment.pug
  13. 21
      app/views/comment/components/composer.pug
  14. 2
      app/views/comment/components/reply-list-standalone.pug
  15. 38
      app/views/comment/components/section.pug
  16. 4
      app/views/components/off-canvas.pug
  17. 189
      client/js/site-app.js
  18. 244
      client/js/site-comments.js

23
app/controllers/announcement.js

@ -15,11 +15,22 @@ class AnnouncementController extends SiteController {
}
async start ( ) {
const { comment: commentService } = this.dtp.services;
const router = express.Router();
this.dtp.app.use('/announcement', router);
const upload = this.createMulter();
router.use(async (req, res, next) => {
res.locals.currentView = 'announcement';
return next();
});
router.param('announcementId', this.populateAnnouncementId.bind(this));
router.post('/:announcementId/comment', upload.none(), commentService.commentCreateHandler('Announcement', 'announcement'));
router.get('/:announcementId', this.getAnnouncementView.bind(this));
router.get('/', this.getHome.bind(this));
@ -40,8 +51,16 @@ class AnnouncementController extends SiteController {
}
}
async getAnnouncementView (req, res) {
res.render('announcement/view');
async getAnnouncementView (req, res, next) {
const { comment: commentService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 10);
res.locals.comments = await commentService.getForResource(res.locals.announcement, ['published'], res.locals.pagination);
res.render('announcement/view');
} catch (error) {
this.log.error('failed to render announcement view', { error });
return next(error);
}
}
async getHome (req, res, next) {

157
app/controllers/comment.js

@ -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); },
};

7
app/models/comment.js

@ -20,7 +20,12 @@ const {
CommentStatsDefaults,
} = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const COMMENT_STATUS_LIST = ['published', 'removed', 'mod-warn', 'mod-removed'];
const COMMENT_STATUS_LIST = [
'published',
'removed',
'mod-warn',
'mod-removed',
];
const CommentSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },

5
app/models/lib/resource-stats.js

@ -8,7 +8,10 @@ const mongoose = require('mongoose');
const Schema = mongoose.Schema;
module.exports.RESOURCE_TYPE_LIST = ['Page', 'Post'];
module.exports.RESOURCE_TYPE_LIST = [
'Announcement',
'Newsletter',
];
module.exports.ResourceStats = new Schema({
uniqueVisitCount: { type: Number, default: 0, required: true, index: -1 },

6
app/models/resource-view.js

@ -4,11 +4,13 @@
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const RESOURCE_TYPE_LIST = ['Page', 'Post', 'Newsletter'];
const { RESOURCE_TYPE_LIST } = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const ResourceViewSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1, expires: '30d' },
@ -27,4 +29,4 @@ ResourceViewSchema.index({
name: 'res_view_daily_unique',
});
module.exports = mongoose.model('ResourceView', ResourceViewSchema);
module.exports = mongoose.model('ResourceView', ResourceViewSchema);

46
app/services/comment.js

@ -75,6 +75,52 @@ class CommentService extends SiteService {
}
}
commentCreateHandler (resourceType, resourceKey) {
const { displayEngine: displayEngineService } = this.dtp.services;
return async (req, res, next) => {
try {
res.locals.comment = await this.create(
req.user,
resourceType,
res.locals[resourceKey],
req.body,
);
let viewModel = Object.assign({ }, req.app.locals);
viewModel = Object.assign(viewModel, res.locals);
const html = await this.renderTemplate('comment', viewModel);
const displayList = displayEngineService.createDisplayList('announcement-comment');
displayList.setInputValue('textarea#content', '');
displayList.setTextContent('#comment-character-count', '0');
if (req.body.replyTo) {
const replyListSelector = `.dtp-reply-list-container[data-comment-id="${req.body.replyTo}"]`;
displayList.addElement(replyListSelector, 'afterBegin', html);
displayList.removeAttribute(replyListSelector, 'hidden');
} else {
displayList.addElement('ul#post-comment-list', 'afterBegin', html);
}
displayList.showNotification(
'Comment created',
'success',
'bottom-center',
4000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to process comment', {
resourceType,
resourceId: res.locals[resourceKey]._id,
error,
});
return next(error);
}
};
}
async create (author, resourceType, resource, commentDefinition) {
const NOW = new Date();
let comment = new Comment();

16
app/views/announcement/components/announcement.pug

@ -6,7 +6,15 @@ mixin renderAnnouncement (announcement)
i(class=`fas ${announcement.title.icon.class}`, style=`color: ${announcement.title.icon.color}`)
span.uk-margin-small-left= announcement.title.content
.uk-card-body!= marked.parse(announcement.content, { renderer: marked.Renderer() })
.uk-card-footer
.uk-text-small.uk-text-muted.uk-flex.uk-flex-between
div= moment(announcement.created).format('MMM DD, YYYY')
div= moment(announcement.created).format('hh:mm a')
.uk-card-footer.uk-text-small.uk-text-muted
div(uk-grid).uk-grid-small.uk-grid-divider
.uk-width-auto
div= moment(announcement.created).format('MMM DD, YYYY')
.uk-width-auto
div= moment(announcement.created).format('hh:mm a')
if currentView !== 'announcement'
.uk-width-auto
a(href=`/announcement/${announcement._id}`)
span
i.fas.fa-link
span.uk-margin-small-left Open Announcement

11
app/views/announcement/view.pug

@ -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` })

2
app/views/comment/components/comment-list-standalone.pug

@ -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 })

9
app/views/comment/components/comment-list.pug

@ -4,7 +4,10 @@ mixin renderCommentList (comments, options = { })
if Array.isArray(comments) && (comments.length > 0)
each comment in comments
li(data-comment-id= comment._id)
+renderComment(comment)
-
var commentOptions = Object.assign({ }, options);
commentOptions.name = `${options.name}-reply-${comment._id}`;
+renderComment(comment, commentOptions)
if (comments.length >= options.countPerPage)
- var buttonId = mongoose.Types.ObjectId();
@ -13,7 +16,7 @@ mixin renderCommentList (comments, options = { })
type="button",
data-button-id= buttonId,
data-post-id= post._id,
data-next-page= pagination.p + 1,
data-next-page= options.pagination ? options.pagination.p + 1 : 2,
data-root-url= options.rootUrl,
onclick= `return dtp.app.loadMoreComments(event);`,
onclick= `return dtp.app.comments['${options.name}'].loadMoreComments(event);`,
).uk-button.dtp-button-primary LOAD MORE

2
app/views/comment/components/comment-standalone.pug

@ -1,4 +1,4 @@
include ../../components/library
include comment
include composer
+renderComment(comment)
+renderComment(comment, appletOptions || { })

25
app/views/comment/components/comment.pug

@ -1,6 +1,6 @@
include composer
mixin renderComment (comment)
mixin renderComment (comment, options)
- var resourceId = comment.resource._id || comment.resource;
article(data-comment-id= comment._id).uk-comment.dtp-site-comment
header.uk-comment-header
@ -28,7 +28,7 @@ mixin renderComment (comment)
a(
href="",
data-comment-id= comment._id,
onclick="return dtp.app.deleteComment(event);",
onclick=`return dtp.app.comments['${options.name}'].deleteComment(event);`,
) Delete
else if user
li.uk-nav-header.no-select Moderation menu
@ -38,7 +38,7 @@ mixin renderComment (comment)
data-resource-type= comment.resourceType,
data-resource-id= resourceId,
data-comment-id= comment._id,
onclick="return dtp.app.showReportCommentForm(event);",
onclick=`return dtp.app.comments['${options.name}'].showReportCommentForm(event);`,
) Report
li
a(
@ -46,7 +46,7 @@ mixin renderComment (comment)
data-resource-type= comment.resourceType,
data-resource-id= resourceId,
data-comment-id= comment._id,
onclick="return dtp.app.blockCommentAuthor(event);",
onclick=`return dtp.app.comments['${options.name}'].blockCommentAuthor(event);`,
) Block author
.uk-comment-body
@ -82,7 +82,7 @@ mixin renderComment (comment)
type="button",
data-comment-id= comment._id,
data-vote="up",
onclick="return dtp.app.submitCommentVote(event);",
onclick=`return dtp.app.comments['${options.name}'].submitCommentVote(event);`,
title="Upvote this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-chevron-up', formatCount(comment.stats.upvoteCount))
@ -91,7 +91,7 @@ mixin renderComment (comment)
type="button",
data-comment-id= comment._id,
data-vote="down",
onclick="return dtp.app.submitCommentVote(event);",
onclick=`return dtp.app.comments['${options.name}'].submitCommentVote(event);`,
title="Downvote this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-chevron-down', formatCount(comment.stats.downvoteCount))
@ -99,7 +99,7 @@ mixin renderComment (comment)
button(
type="button",
data-comment-id= comment._id,
onclick="return dtp.app.openReplies(event);",
onclick=`return dtp.app.comments['${options.name}'].openReplies(event);`,
title="Load replies to this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-comment', formatCount(comment.stats.replyCount))
@ -107,16 +107,21 @@ mixin renderComment (comment)
button(
type="button",
data-comment-id= comment._id,
onclick="return dtp.app.openReplyComposer(event);",
onclick=`return dtp.app.comments['${options.name}'].openReplyComposer(event);`,
title="Write a reply to this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-reply', 'reply')
//- Comment replies and reply composer
div(data-comment-id= comment._id, hidden).dtp-reply-composer.uk-margin
div(
data-comment-id= comment._id,
data-root-url= options.rootUrl,
dtp-comments= options.name,
hidden,
).dtp-reply-composer.uk-margin
if user && user.permissions.canComment
.uk-margin
+renderCommentComposer(`/post/${comment.resource._id}/comment`, { showCancel: true, replyTo: comment._id })
+renderCommentComposer(Object.assign({ showCancel: true, replyTo: comment._id }, options))
div(data-comment-id= comment._id, hidden).dtp-reply-list-container.uk-margin
ul(data-comment-id= comment._id).dtp-reply-list.uk-list.uk-margin-medium-left

21
app/views/comment/components/composer.pug

@ -1,23 +1,23 @@
mixin renderCommentComposer (actionUrl, options = { })
form(method="POST", action= actionUrl, onsubmit="return dtp.app.submitForm(event, 'create-comment');").uk-form
mixin renderCommentComposer (options = { })
form(method="POST", action= options.rootUrl, onsubmit="return dtp.app.submitForm(event, 'create-comment');").uk-form
if options.replyTo
input(type="hidden", name="replyTo", value= options.replyTo)
.uk-card.uk-card-secondary.uk-card-small
.uk-card-body
textarea(
id="content",
textarea#comment-content(
name="content",
rows="4",
maxlength="3000",
placeholder="Enter comment",
oninput="return dtp.app.onCommentInput(event);",
oninput=`return dtp.app.comments['${options.name}'].onCommentInput(event);`,
).uk-textarea.uk-resize-vertical
.uk-text-small
div(uk-grid).uk-flex-between
.uk-width-auto You are commenting as: #{user.username}
.uk-width-auto #[span#comment-character-count 0] of 3,000
.uk-width-auto #[span.comment-character-count 0] of 3,000
.uk-card-footer
div(uk-grid).uk-flex-between.uk-grid-small
.uk-width-expand
@ -25,12 +25,14 @@ mixin renderCommentComposer (actionUrl, options = { })
li
button(
type="button",
data-target-element="content",
title="Add an emoji",
onclick="return dtp.app.showEmojiPicker(event);",
uk-tooltip="Add an emoji",
).uk-button.dtp-button-default
span
i.far.fa-smile
#comment-emoji-picker(uk-drop={ mode: 'click' })
.comment-emoji-picker
div THIS IS THE EMOJI PICKER
li(title="Not Safe For Work will hide your comment text by default")
label
input(id="is-nsfw", name="isNSFW", type="checkbox").uk-checkbox
@ -39,5 +41,6 @@ mixin renderCommentComposer (actionUrl, options = { })
if options.showCancel
.uk-width-auto
button(type="submit").uk-button.dtp-button-secondary Cancel
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary Post

2
app/views/comment/components/reply-list-standalone.pug

@ -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 })

38
app/views/comment/components/section.pug

@ -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.

4
app/views/components/off-canvas.pug

@ -20,6 +20,10 @@ mixin renderMenuItem (iconClass, label)
a(href='/').uk-display-block
+renderMenuItem('fa-home', 'Home')
li(class={ "uk-active": (currentView === 'announcement') })
a(href='/announcement').uk-display-block
+renderMenuItem('fa-bullhorn', 'Announcements')
if user
li.uk-nav-header Member Menu

189
client/js/site-app.js

@ -14,6 +14,7 @@ import QRCode from 'qrcode';
import Cropper from 'cropperjs';
import SiteChat from './site-chat';
import SiteComments from './site-comments';
const GRID_COLOR = 'rgb(64, 64, 64)';
const GRID_TICK_COLOR = 'rgb(192,192,192)';
@ -42,11 +43,26 @@ export default class DtpSiteApp extends DtpApp {
});
this.chat = new SiteChat(this);
this.initializeComments();
this.charts = { /* will hold rendered charts */ };
this.scrollToHash();
}
initializeComments ( ) {
this.comments = { };
const containers = document.querySelectorAll('[dtp-comments]');
containers.forEach((container) => {
const name = container.getAttribute('dtp-comments');
const rootUrl = container.getAttribute('data-root-url');
this.log.info('initializeComments', 'initializing commenting scope', { name, rootUrl });
this.comments[name] = new SiteComments(this, container);
});
}
async scrollToHash ( ) {
const { hash } = window.location;
if (hash === '') {
@ -411,125 +427,11 @@ export default class DtpSiteApp extends DtpApp {
UIkit.modal.alert(`Failed to remove image: ${error.message}`);
}
}
async onCommentInput (event) {
const label = document.getElementById('comment-character-count');
label.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.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to delete comment: ${error.message}`);
}
}
async submitDialogForm (event, userAction) {
await this.submitForm(event, userAction);
await this.closeCurrentDialog();
}
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.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.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to submit vote: ${error.message}`);
}
}
async renderStatsGraph (selector, title, data) {
try {
const canvas = document.querySelector(selector);
@ -602,65 +504,6 @@ export default class DtpSiteApp extends DtpApp {
UIkit.modal.alert(`Failed to render chart: ${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.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.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to load more comments: ${error.message}`);
}
}
}
dtp.DtpSiteApp = DtpSiteApp;

244
client/js/site-comments.js

@ -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…
Cancel
Save