diff --git a/client/css/comments.styl b/client/css/comments.styl index dafdaa9..c1583b7 100644 --- a/client/css/comments.styl +++ b/client/css/comments.styl @@ -5,18 +5,15 @@ margin: 0 0 2em 0 padding: 0 -.comment - margin: 0 0 1em 0 - padding: 0 - display: -webkit-flex - display: flex + +.comment-form-container &:not(.editing) .tabs nav display: none .tabs .edit.tab display: none - .content + .comment-content margin-left: 0.5em &.editing .tab:not(.active) @@ -25,10 +22,10 @@ background: $active-tab-background-color .tab padding: 1em - .content-wrapper + .comment-content-wrapper background: $window-color overflow: hidden - .content + .comment-content margin: 1em textarea resize: vertical @@ -36,6 +33,27 @@ max-height: 80vh box-sizing: padding-box + form + width: auto + margin: 0 + + nav + vertical-align: middle !important + margin: 0 0.3em 0.5em 0 !important + &.buttons + float: left + &.actions + float: left + margin-top: 0.3em !important + + + +.comment + margin: 0 0 1em 0 + padding: 0 + display: -webkit-flex + display: flex + .avatar margin-right: 1em -webkit-flex-shrink: 0 @@ -80,64 +98,55 @@ display: inline-block width: 2em - form - width: auto - margin: 0 - - nav - vertical-align: middle - margin: 0 0.8em 0.5em 0 - &.buttons - float: left - &.actions - float: left - margin-top: 0.3em - .messages margin: 1em 0 - .content - ul - list-style-position: inside - margin: 1em 0 - padding: 0 - .sjis - font-family: 'MS PGothic', 'MS Pゴシック', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif - background: #fbfbfb - color: #111 - font-size: 12pt - line-height: 1 - margin: 0 - padding: 4px - overflow: auto - white-space: pre - word-wrap: normal - p:first-child - margin-top: 0 +.comment-content + ul + list-style-position: inside + margin: 1em 0 + padding: 0 - .spoiler - background: #eee - color: #eee - &:hover - color: dimgray - &:before - content: '[' - color: #000 - &:after - content: ']' - color: #000 + .sjis + font-family: 'MS PGothic', 'MS Pゴシック', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif + background: #fbfbfb + color: #111 + font-size: 12pt + line-height: 1 + margin: 0 + padding: 4px + overflow: auto + white-space: pre + word-wrap: normal + + p:first-child + margin-top: 0 + + .spoiler + background: #eee + color: #eee + &:hover + color: dimgray + &:before + content: '[' + color: #000 + &:after + content: ']' + color: #000 + + blockquote + border-left: 3px solid #eee + margin-left: 0 + padding: 0.3em 0.3em 0.3em 0.7em + background: #fafafa + color: #444 + + blockquote :last-child + margin-bottom: 0 - blockquote - border-left: 3px solid #eee - margin-left: 0 - padding: 0.3em 0.3em 0.3em 0.7em - background: #fafafa - color: #444 - blockquote :last-child - margin-bottom: 0 .global-comment-list text-align: left diff --git a/client/html/comment.tpl b/client/html/comment.tpl index aac6346..32eba69 100644 --- a/client/html/comment.tpl +++ b/client/html/comment.tpl @@ -44,32 +44,6 @@ --><% } %> -
-
-
-
-
<%= ctx.makeMarkdown(ctx.comment.text) %>
-
- -
- -
-
- - - - -
- -
-
+
diff --git a/client/html/comment_form.tpl b/client/html/comment_form.tpl new file mode 100644 index 0000000..ec35386 --- /dev/null +++ b/client/html/comment_form.tpl @@ -0,0 +1,31 @@ +
+
+
<%= ctx.makeMarkdown(ctx.comment.text) %>
+ + + + +
+ +
+
diff --git a/client/html/post.tpl b/client/html/post.tpl index 39bf9d6..d6d3030 100644 --- a/client/html/post.tpl +++ b/client/html/post.tpl @@ -46,6 +46,13 @@
-
+ <% if (ctx.canListComments) { %> +
+ <% } %> + + <% if (ctx.canCreateComments) { %> +

Add comment

+
+ <% } %>
diff --git a/client/js/controllers/posts_controller.js b/client/js/controllers/posts_controller.js index 2765f9f..b22b7be 100644 --- a/client/js/controllers/posts_controller.js +++ b/client/js/controllers/posts_controller.js @@ -74,6 +74,8 @@ class PostsController { nextPostId: aroundResponse.next ? aroundResponse.next.id : null, prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null, canEditPosts: api.hasPrivilege('posts:edit'), + canListComments: api.hasPrivilege('comments:list'), + canCreateComments: api.hasPrivilege('comments:create'), }); }, response => { this._emptyView.render(); diff --git a/client/js/controls/comment_control.js b/client/js/controls/comment_control.js index 5ca252a..5e283d4 100644 --- a/client/js/controls/comment_control.js +++ b/client/js/controls/comment_control.js @@ -1,15 +1,16 @@ 'use strict'; const api = require('../api.js'); -const misc = require('../util/misc.js'); const views = require('../util/views.js'); +const CommentFormControl = require('../controls/comment_form_control.js'); class CommentControl { - constructor(hostNode, comment) { + constructor(hostNode, comment, settings) { this._hostNode = hostNode; this._comment = comment; this._template = views.getTemplate('comment'); this._scoreTemplate = views.getTemplate('score'); + this._settings = settings; this.install(); } @@ -36,11 +37,6 @@ class CommentControl { const deleteButton = sourceNode.querySelector('.delete'); const upvoteButton = sourceNode.querySelector('.upvote'); const downvoteButton = sourceNode.querySelector('.downvote'); - const previewTabButton = sourceNode.querySelector('.buttons .preview'); - const editTabButton = sourceNode.querySelector('.buttons .edit'); - const formNode = sourceNode.querySelector('form'); - const cancelButton = sourceNode.querySelector('.cancel'); - const textareaNode = sourceNode.querySelector('form textarea'); if (editButton) { editButton.addEventListener( @@ -64,20 +60,22 @@ class CommentControl { e, () => this._comment.ownScore === -1 ? 0 : -1)); } - previewTabButton.addEventListener( - 'click', e => this._evtPreviewClick(e)); - editTabButton.addEventListener( - 'click', e => this._evtEditClick(e)); - - formNode.addEventListener('submit', e => this._evtSaveClick(e)); - cancelButton.addEventListener('click', e => this._evtCancelClick(e)); - - for (let event of ['cut', 'paste', 'drop', 'keydown']) { - textareaNode.addEventListener(event, e => { - window.setTimeout(() => this._growTextArea(), 0); + this._formControl = new CommentFormControl( + sourceNode.querySelector('.comment-form-container'), + this._comment, + { + onSave: text => { + return api.put('/comment/' + this._comment.id, { + text: text, + }).then(response => { + this._comment = response; + this.install(); + }, response => { + this._formControl.showError(response.description); + }); + }, + canCancel: true }); - } - textareaNode.addEventListener('change', e => { this._growTextArea(); }); views.showView(this._hostNode, sourceNode); } @@ -97,6 +95,11 @@ class CommentControl { }); } + _evtEditClick(e) { + e.preventDefault(); + this._formControl.enterEditMode(); + } + _evtDeleteClick(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to delete this comment?')) { @@ -104,82 +107,14 @@ class CommentControl { } api.delete('/comment/' + this._comment.id) .then(response => { + if (this._settings.onDelete) { + this._settings.onDelete(this._comment); + } this._hostNode.parentNode.removeChild(this._hostNode); }, response => { window.alert(response.description); }); } - - _evtSaveClick(e) { - e.preventDefault(); - api.put('/comment/' + this._comment.id, { - text: this._hostNode.querySelector('.edit.tab textarea').value, - }).then(response => { - this._comment = response; - this.install(); - }, response => { - this._showError(response.description); - }); - } - - _evtPreviewClick(e) { - e.preventDefault(); - this._hostNode.querySelector('.preview.tab .content').innerHTML - = misc.formatMarkdown( - this._hostNode.querySelector('.edit.tab textarea').value); - this._freezeTabHeights(); - this._selectTab('preview'); - } - - _evtEditClick(e) { - e.preventDefault(); - this._freezeTabHeights(); - this._enterEditMode(); - this._selectTab('edit'); - this._growTextArea(); - } - - _evtCancelClick(e) { - e.preventDefault(); - this._exitEditMode(); - this._hostNode.querySelector('.edit.tab textarea').value - = this._comment.text; - } - - _enterEditMode() { - this._hostNode.querySelector('.comment').classList.add('editing'); - misc.enableExitConfirmation(); - } - - _exitEditMode() { - this._hostNode.querySelector('.comment').classList.remove('editing'); - this._hostNode.querySelector('.tabs-wrapper').style.minHeight = null; - misc.disableExitConfirmation(); - views.clearMessages(this._hostNode); - } - - _selectTab(tabName) { - this._freezeTabHeights(); - for (let tab of this._hostNode.querySelectorAll('.tab, .buttons li')) { - tab.classList.toggle('active', tab.classList.contains(tabName)); - } - } - - _freezeTabHeights() { - const tabsNode = this._hostNode.querySelector('.tabs-wrapper'); - const tabsHeight = tabsNode.getBoundingClientRect().height; - tabsNode.style.minHeight = tabsHeight + 'px'; - } - - _growTextArea() { - const previewNode = this._hostNode.querySelector('.content'); - const textareaNode = this._hostNode.querySelector('textarea'); - textareaNode.style.height = textareaNode.scrollHeight + 'px'; - } - - _showError(message) { - views.showError(this._hostNode, message); - } }; module.exports = CommentControl; diff --git a/client/js/controls/comment_form_control.js b/client/js/controls/comment_form_control.js new file mode 100644 index 0000000..94e6fdc --- /dev/null +++ b/client/js/controls/comment_form_control.js @@ -0,0 +1,134 @@ +'use strict'; + +const misc = require('../util/misc.js'); +const views = require('../util/views.js'); + +class CommentFormControl { + constructor(hostNode, comment, settings) { + this._hostNode = hostNode; + this._comment = comment || {text: ''}; + this._template = views.getTemplate('comment-form'); + this._settings = settings; + this.install(); + } + + install() { + const sourceNode = this._template({ + comment: this._comment, + }); + + const previewTabButton = sourceNode.querySelector('.buttons .preview'); + const editTabButton = sourceNode.querySelector('.buttons .edit'); + const formNode = sourceNode.querySelector('form'); + const cancelButton = sourceNode.querySelector('.cancel'); + const textareaNode = sourceNode.querySelector('form textarea'); + + previewTabButton.addEventListener( + 'click', e => this._evtPreviewClick(e)); + editTabButton.addEventListener( + 'click', e => this._evtEditClick(e)); + + formNode.addEventListener('submit', e => this._evtSaveClick(e)); + + if (this._settings.canCancel) { + cancelButton + .addEventListener('click', e => this._evtCancelClick(e)); + } else { + cancelButton.style.display = 'none'; + } + + for (let event of ['cut', 'paste', 'drop', 'keydown']) { + textareaNode.addEventListener(event, e => { + window.setTimeout(() => this._growTextArea(), 0); + }); + } + textareaNode.addEventListener('change', e => { + misc.enableExitConfirmation(); + this._growTextArea(); + }); + + views.showView(this._hostNode, sourceNode); + } + + enterEditMode() { + this._freezeTabHeights(); + this._hostNode.classList.add('editing'); + this._selectTab('edit'); + this._growTextArea(); + } + + exitEditMode() { + this._hostNode.classList.remove('editing'); + this._hostNode.querySelector('.tabs-wrapper').style.minHeight = null; + misc.disableExitConfirmation(); + views.clearMessages(this._hostNode); + this._hostNode.querySelector('.edit.tab textarea').value + = this._comment.text; + } + + get _textareaNode() { + return this._hostNode.querySelector('.edit.tab textarea'); + } + + get _contentNode() { + return this._hostNode.querySelector('.preview.tab .comment-content'); + } + + setText(text) { + this._textareaNode.value = text; + this._contentNode.innerHTML = misc.formatMarkdown(text); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + _evtPreviewClick(e) { + e.preventDefault(); + this._contentNode.innerHTML + = misc.formatMarkdown(this._textareaNode.value); + this._freezeTabHeights(); + this._selectTab('preview'); + } + + _evtEditClick(e) { + e.preventDefault(); + this.enterEditMode(); + } + + _evtSaveClick(e) { + e.preventDefault(); + if (!this._settings.onSave) { + throw 'No save handler'; + } + this._settings.onSave(this._textareaNode.value) + .then(() => { misc.disableExitConfirmation(); }); + } + + _evtCancelClick(e) { + e.preventDefault(); + this.exitEditMode(); + } + + _selectTab(tabName) { + this._freezeTabHeights(); + for (let tab of this._hostNode.querySelectorAll('.tab, .buttons li')) { + tab.classList.toggle('active', tab.classList.contains(tabName)); + } + } + + _freezeTabHeights() { + const tabsNode = this._hostNode.querySelector('.tabs-wrapper'); + const tabsHeight = tabsNode.getBoundingClientRect().height; + tabsNode.style.minHeight = tabsHeight + 'px'; + } + + _growTextArea() { + this._textareaNode.style.height + = Math.max( + this._settings.minHeight || 0, + this._textareaNode.scrollHeight) + 'px'; + } +}; + +module.exports = CommentFormControl; diff --git a/client/js/controls/comment_list_control.js b/client/js/controls/comment_list_control.js index d78a6ad..91b13f8 100644 --- a/client/js/controls/comment_list_control.js +++ b/client/js/controls/comment_list_control.js @@ -31,7 +31,15 @@ class CommentListControl { const commentList = new DocumentFragment(); for (let comment of this._comments) { const commentListItemNode = document.createElement('li'); - new CommentControl(commentListItemNode, comment); + new CommentControl(commentListItemNode, comment, { + onDelete: removedComment => { + for (let [index, comment] of this._comments.entries()) { + if (comment.id === removedComment.id) { + this._comments.splice(index, 1); + } + } + }, + }); commentList.appendChild(commentListItemNode); } views.showView(this._hostNode.querySelector('ul'), commentList); diff --git a/client/js/views/post_view.js b/client/js/views/post_view.js index 0c7b541..0968682 100644 --- a/client/js/views/post_view.js +++ b/client/js/views/post_view.js @@ -1,5 +1,6 @@ 'use strict'; +const api = require('../api.js'); const views = require('../util/views.js'); const keyboard = require('../util/keyboard.js'); const page = require('page'); @@ -11,6 +12,7 @@ const PostReadonlySidebarControl const PostEditSidebarControl = require('../controls/post_edit_sidebar_control.js'); const CommentListControl = require('../controls/comment_list_control.js'); +const CommentFormControl = require('../controls/comment_form_control.js'); class PostView { constructor() { @@ -52,21 +54,9 @@ class PostView { postContainerNode.querySelector('.post-overlay'), ctx.post); - if (ctx.editMode) { - new PostEditSidebarControl( - postViewNode.querySelector('.sidebar-container'), - ctx.post, - this._postContentControl); - } else { - new PostReadonlySidebarControl( - postViewNode.querySelector('.sidebar-container'), - ctx.post, - this._postContentControl); - } - - new CommentListControl( - postViewNode.querySelector('.comments-container'), - ctx.post.comments); + this._installSidebar(ctx); + this._installCommentForm(ctx); + this._installComments(ctx); keyboard.bind('e', () => { if (ctx.editMode) { @@ -86,6 +76,56 @@ class PostView { } }); } + + _installSidebar(ctx) { + const sidebarContainerNode = document.querySelector( + '#content-holder .sidebar-container'); + + if (ctx.editMode) { + new PostEditSidebarControl( + sidebarContainerNode, ctx.post, this._postContentControl); + } else { + new PostReadonlySidebarControl( + sidebarContainerNode, ctx.post, this._postContentControl); + } + } + + _installCommentForm(ctx) { + const commentFormContainer = document.querySelector( + '#content-holder .comment-form-container'); + if (!commentFormContainer) { + return; + } + + this._formControl = new CommentFormControl( + commentFormContainer, + null, + { + onSave: text => { + return api.post('/comments', { + postId: ctx.post.id, + text: text, + }).then(response => { + ctx.post.comments.push(response); + this._formControl.setText(''); + this._installComments(ctx); + }, response => { + this._formControl.showError(response.description); + }); + }, + canCancel: false, + minHeight: 150, + }); + this._formControl.enterEditMode(); + } + + _installComments(ctx) { + const commentsContainerNode = document.querySelector( + '#content-holder .comments-container'); + if (commentsContainerNode) { + new CommentListControl(commentsContainerNode, ctx.post.comments); + } + } } module.exports = PostView;