From 8e872cd688bce2924c1e8e59ffc4c63c75d6d4eb Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 19 Aug 2022 21:53:31 -0400 Subject: [PATCH] Comments update to make them more resource agnostic and usable --- app/controllers/announcement.js | 23 +- app/controllers/comment.js | 157 +++++++++++ app/models/comment.js | 7 +- app/models/lib/resource-stats.js | 5 +- app/models/resource-view.js | 6 +- app/services/comment.js | 46 ++++ .../announcement/components/announcement.pug | 16 +- app/views/announcement/view.pug | 11 + .../components/comment-list-standalone.pug | 2 +- app/views/comment/components/comment-list.pug | 9 +- .../comment/components/comment-standalone.pug | 2 +- app/views/comment/components/comment.pug | 25 +- app/views/comment/components/composer.pug | 21 +- .../components/reply-list-standalone.pug | 2 +- app/views/comment/components/section.pug | 38 +++ app/views/components/off-canvas.pug | 4 + client/js/site-app.js | 189 ++------------ client/js/site-comments.js | 244 ++++++++++++++++++ 18 files changed, 599 insertions(+), 208 deletions(-) create mode 100644 app/controllers/comment.js create mode 100644 app/views/announcement/view.pug create mode 100644 app/views/comment/components/section.pug create mode 100644 client/js/site-comments.js diff --git a/app/controllers/announcement.js b/app/controllers/announcement.js index 269291b..ea55350 100644 --- a/app/controllers/announcement.js +++ b/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) { diff --git a/app/controllers/comment.js b/app/controllers/comment.js new file mode 100644 index 0000000..a40cbaa --- /dev/null +++ b/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); }, +}; \ No newline at end of file diff --git a/app/models/comment.js b/app/models/comment.js index 2ea385e..edc08e8 100644 --- a/app/models/comment.js +++ b/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 }, diff --git a/app/models/lib/resource-stats.js b/app/models/lib/resource-stats.js index 016644d..1e4b401 100644 --- a/app/models/lib/resource-stats.js +++ b/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 }, diff --git a/app/models/resource-view.js b/app/models/resource-view.js index ec00268..d59ad0a 100644 --- a/app/models/resource-view.js +++ b/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); \ No newline at end of file diff --git a/app/services/comment.js b/app/services/comment.js index 191483f..13976e3 100644 --- a/app/services/comment.js +++ b/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(); diff --git a/app/views/announcement/components/announcement.pug b/app/views/announcement/components/announcement.pug index 91a5625..2b169c5 100644 --- a/app/views/announcement/components/announcement.pug +++ b/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') \ No newline at end of file + .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 \ No newline at end of file diff --git a/app/views/announcement/view.pug b/app/views/announcement/view.pug new file mode 100644 index 0000000..1ad8a26 --- /dev/null +++ b/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` }) \ No newline at end of file diff --git a/app/views/comment/components/comment-list-standalone.pug b/app/views/comment/components/comment-list-standalone.pug index c6bff35..30cd9ee 100644 --- a/app/views/comment/components/comment-list-standalone.pug +++ b/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 }) \ No newline at end of file ++renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/post/${post.slug}/comment`, countPerPage }) \ No newline at end of file diff --git a/app/views/comment/components/comment-list.pug b/app/views/comment/components/comment-list.pug index 585ff74..1da58ac 100644 --- a/app/views/comment/components/comment-list.pug +++ b/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 \ No newline at end of file diff --git a/app/views/comment/components/comment-standalone.pug b/app/views/comment/components/comment-standalone.pug index 46bcb54..743fe79 100644 --- a/app/views/comment/components/comment-standalone.pug +++ b/app/views/comment/components/comment-standalone.pug @@ -1,4 +1,4 @@ include ../../components/library include comment include composer -+renderComment(comment) \ No newline at end of file ++renderComment(comment, appletOptions || { }) \ No newline at end of file diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index 5fc5e53..d85aeac 100644 --- a/app/views/comment/components/comment.pug +++ b/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 \ No newline at end of file diff --git a/app/views/comment/components/composer.pug b/app/views/comment/components/composer.pug index 2513657..98dfeea 100644 --- a/app/views/comment/components/composer.pug +++ b/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 \ No newline at end of file diff --git a/app/views/comment/components/reply-list-standalone.pug b/app/views/comment/components/reply-list-standalone.pug index a5e3fd8..b6eee33 100644 --- a/app/views/comment/components/reply-list-standalone.pug +++ b/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 }) \ No newline at end of file ++renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/comment/${comment._id}/replies`, countPerPage }) \ No newline at end of file diff --git a/app/views/comment/components/section.pug b/app/views/comment/components/section.pug new file mode 100644 index 0000000..bc3accf --- /dev/null +++ b/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. \ No newline at end of file diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index fcc04e4..2296d56 100644 --- a/app/views/components/off-canvas.pug +++ b/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 diff --git a/client/js/site-app.js b/client/js/site-app.js index e8a51d9..0763554 100644 --- a/client/js/site-app.js +++ b/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; \ No newline at end of file diff --git a/client/js/site-comments.js b/client/js/site-comments.js new file mode 100644 index 0000000..3f020e7 --- /dev/null +++ b/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; + } + } +} \ No newline at end of file