client/comments: add comment adding

This commit is contained in:
rr- 2016-06-12 18:08:50 +02:00
parent b9fc626ace
commit a28b4bdd3e
9 changed files with 333 additions and 193 deletions

View file

@ -5,18 +5,15 @@
margin: 0 0 2em 0 margin: 0 0 2em 0
padding: 0 padding: 0
.comment
margin: 0 0 1em 0
padding: 0
display: -webkit-flex
display: flex
.comment-form-container
&:not(.editing) &:not(.editing)
.tabs nav .tabs nav
display: none display: none
.tabs .edit.tab .tabs .edit.tab
display: none display: none
.content .comment-content
margin-left: 0.5em margin-left: 0.5em
&.editing &.editing
.tab:not(.active) .tab:not(.active)
@ -25,10 +22,10 @@
background: $active-tab-background-color background: $active-tab-background-color
.tab .tab
padding: 1em padding: 1em
.content-wrapper .comment-content-wrapper
background: $window-color background: $window-color
overflow: hidden overflow: hidden
.content .comment-content
margin: 1em margin: 1em
textarea textarea
resize: vertical resize: vertical
@ -36,6 +33,27 @@
max-height: 80vh max-height: 80vh
box-sizing: padding-box 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 .avatar
margin-right: 1em margin-right: 1em
-webkit-flex-shrink: 0 -webkit-flex-shrink: 0
@ -80,64 +98,55 @@
display: inline-block display: inline-block
width: 2em 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 .messages
margin: 1em 0 margin: 1em 0
.content
ul
list-style-position: inside
margin: 1em 0
padding: 0
.sjis
font-family: 'MS PGothic', ' ', '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 .comment-content
margin-top: 0 ul
list-style-position: inside
margin: 1em 0
padding: 0
.spoiler .sjis
background: #eee font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
color: #eee background: #fbfbfb
&:hover color: #111
color: dimgray font-size: 12pt
&:before line-height: 1
content: '[' margin: 0
color: #000 padding: 4px
&:after overflow: auto
content: ']' white-space: pre
color: #000 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 .global-comment-list
text-align: left text-align: left

View file

@ -44,32 +44,6 @@
--><% } %><!-- --><% } %><!--
--></header> --></header>
<div class='tabs'> <div class='comment-form-container'></div>
<form>
<div class='tabs-wrapper'>
<div class='preview tab'>
<div class='content-wrapper'><div class='content'><%= ctx.makeMarkdown(ctx.comment.text) %></div></div>
</div>
<div class='edit tab'>
<textarea required minlength=1><%= ctx.comment.text %></textarea>
</div>
</div>
<nav class='buttons'>
<ul>
<li class='preview'><a href='#'>Preview</a></li>
<li class='edit'><a href='#'>Edit</a></li>
</ul>
</nav>
<nav class='actions'>
<input type='submit' class='save' value='Save'/>
<input type='button' class='cancel discourage' value='Cancel'/>
</nav>
</form>
<div class='messages'></div>
</div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,31 @@
<div class='tabs'>
<form>
<div class='tabs-wrapper'><!--
--><div class='preview tab'><!--
--><div class='comment-content-wrapper'><!--
--><div class='comment-content'><!--
--><%= ctx.makeMarkdown(ctx.comment.text) %><!--
--></div><!--
--></div><!--
--></div><!--
--><div class='edit tab'><!--
--><textarea required minlength=1><%= ctx.comment.text %></textarea><!--
--></div><!--
--></div>
<nav class='buttons'>
<ul>
<li class='preview'><a href='#'>Preview</a></li>
<li class='edit'><a href='#'>Edit</a></li>
</ul>
</nav>
<nav class='actions'>
<input type='submit' class='save' value='Save'/>
<input type='button' class='cancel discourage' value='Cancel'/>
</nav>
</form>
<div class='messages'></div>
</div>

View file

@ -46,6 +46,13 @@
<div class='content'> <div class='content'>
<div class='post-container'></div> <div class='post-container'></div>
<div class='comments-container'></div> <% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>
<% if (ctx.canCreateComments) { %>
<h2>Add comment</h2>
<div class='comment-form-container'></div>
<% } %>
</div> </div>
</div> </div>

View file

@ -74,6 +74,8 @@ class PostsController {
nextPostId: aroundResponse.next ? aroundResponse.next.id : null, nextPostId: aroundResponse.next ? aroundResponse.next.id : null,
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null, prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
canEditPosts: api.hasPrivilege('posts:edit'), canEditPosts: api.hasPrivilege('posts:edit'),
canListComments: api.hasPrivilege('comments:list'),
canCreateComments: api.hasPrivilege('comments:create'),
}); });
}, response => { }, response => {
this._emptyView.render(); this._emptyView.render();

View file

@ -1,15 +1,16 @@
'use strict'; 'use strict';
const api = require('../api.js'); const api = require('../api.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
const CommentFormControl = require('../controls/comment_form_control.js');
class CommentControl { class CommentControl {
constructor(hostNode, comment) { constructor(hostNode, comment, settings) {
this._hostNode = hostNode; this._hostNode = hostNode;
this._comment = comment; this._comment = comment;
this._template = views.getTemplate('comment'); this._template = views.getTemplate('comment');
this._scoreTemplate = views.getTemplate('score'); this._scoreTemplate = views.getTemplate('score');
this._settings = settings;
this.install(); this.install();
} }
@ -36,11 +37,6 @@ class CommentControl {
const deleteButton = sourceNode.querySelector('.delete'); const deleteButton = sourceNode.querySelector('.delete');
const upvoteButton = sourceNode.querySelector('.upvote'); const upvoteButton = sourceNode.querySelector('.upvote');
const downvoteButton = sourceNode.querySelector('.downvote'); 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) { if (editButton) {
editButton.addEventListener( editButton.addEventListener(
@ -64,20 +60,22 @@ class CommentControl {
e, () => this._comment.ownScore === -1 ? 0 : -1)); e, () => this._comment.ownScore === -1 ? 0 : -1));
} }
previewTabButton.addEventListener( this._formControl = new CommentFormControl(
'click', e => this._evtPreviewClick(e)); sourceNode.querySelector('.comment-form-container'),
editTabButton.addEventListener( this._comment,
'click', e => this._evtEditClick(e)); {
onSave: text => {
formNode.addEventListener('submit', e => this._evtSaveClick(e)); return api.put('/comment/' + this._comment.id, {
cancelButton.addEventListener('click', e => this._evtCancelClick(e)); text: text,
}).then(response => {
for (let event of ['cut', 'paste', 'drop', 'keydown']) { this._comment = response;
textareaNode.addEventListener(event, e => { this.install();
window.setTimeout(() => this._growTextArea(), 0); }, response => {
this._formControl.showError(response.description);
});
},
canCancel: true
}); });
}
textareaNode.addEventListener('change', e => { this._growTextArea(); });
views.showView(this._hostNode, sourceNode); views.showView(this._hostNode, sourceNode);
} }
@ -97,6 +95,11 @@ class CommentControl {
}); });
} }
_evtEditClick(e) {
e.preventDefault();
this._formControl.enterEditMode();
}
_evtDeleteClick(e) { _evtDeleteClick(e) {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you sure you want to delete this comment?')) { if (!window.confirm('Are you sure you want to delete this comment?')) {
@ -104,82 +107,14 @@ class CommentControl {
} }
api.delete('/comment/' + this._comment.id) api.delete('/comment/' + this._comment.id)
.then(response => { .then(response => {
if (this._settings.onDelete) {
this._settings.onDelete(this._comment);
}
this._hostNode.parentNode.removeChild(this._hostNode); this._hostNode.parentNode.removeChild(this._hostNode);
}, response => { }, response => {
window.alert(response.description); 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; module.exports = CommentControl;

View file

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

View file

@ -31,7 +31,15 @@ class CommentListControl {
const commentList = new DocumentFragment(); const commentList = new DocumentFragment();
for (let comment of this._comments) { for (let comment of this._comments) {
const commentListItemNode = document.createElement('li'); 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); commentList.appendChild(commentListItemNode);
} }
views.showView(this._hostNode.querySelector('ul'), commentList); views.showView(this._hostNode.querySelector('ul'), commentList);

View file

@ -1,5 +1,6 @@
'use strict'; 'use strict';
const api = require('../api.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
const keyboard = require('../util/keyboard.js'); const keyboard = require('../util/keyboard.js');
const page = require('page'); const page = require('page');
@ -11,6 +12,7 @@ const PostReadonlySidebarControl
const PostEditSidebarControl const PostEditSidebarControl
= require('../controls/post_edit_sidebar_control.js'); = require('../controls/post_edit_sidebar_control.js');
const CommentListControl = require('../controls/comment_list_control.js'); const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
class PostView { class PostView {
constructor() { constructor() {
@ -52,21 +54,9 @@ class PostView {
postContainerNode.querySelector('.post-overlay'), postContainerNode.querySelector('.post-overlay'),
ctx.post); ctx.post);
if (ctx.editMode) { this._installSidebar(ctx);
new PostEditSidebarControl( this._installCommentForm(ctx);
postViewNode.querySelector('.sidebar-container'), this._installComments(ctx);
ctx.post,
this._postContentControl);
} else {
new PostReadonlySidebarControl(
postViewNode.querySelector('.sidebar-container'),
ctx.post,
this._postContentControl);
}
new CommentListControl(
postViewNode.querySelector('.comments-container'),
ctx.post.comments);
keyboard.bind('e', () => { keyboard.bind('e', () => {
if (ctx.editMode) { 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; module.exports = PostView;