client/comments: add comment list view for post

This commit is contained in:
rr- 2016-06-11 17:41:28 +02:00
parent 0908323290
commit 7e8a9a0948
18 changed files with 581 additions and 59 deletions

View file

@ -1,4 +1,5 @@
$main-color = #24AADD
$window-color = white
$top-nav-color = #F5F5F5
$text-color = #111
$inactive-link-color = #888

136
client/css/comments.styl Normal file
View file

@ -0,0 +1,136 @@
@import colors
.comments>ul
list-style-type: none
margin: 1em 0
padding: 0
.comment
margin: 0 0 1em 0
padding: 0
display: -webkit-flex
display: flex
&:not(.editing)
.tabs nav
display: none
.tabs .edit.tab
display: none
&.editing
.tab:not(.active)
display: none
.tabs-wrapper
background: $active-tab-background-color
.tab
padding: 1em
.content-wrapper
background: $window-color
overflow: hidden
.content
margin: 1em
textarea
resize: vertical
width: 100%
max-height: 80vh
box-sizing: padding-box
.avatar
margin-right: 1em
-webkit-flex-shrink: 0
flex-shrink: 0
vertical-align: top
.thumbnail
width: 40px
height: 40px
margin: 0
.body
width: 100%
header
line-height: 16pt
vertical-align: middle
margin-bottom: 0.5em
background: $top-nav-color
padding: 0.2em 0.5em
.date, .score-container, .edit, .delete
margin-left: 2em
font-size: 95%
.edit, .delete, .score-container a, .nickname a
color: mix($main-color, $inactive-tab-text-color)
.edit, .delete
font-size: 80%
i
margin-right: 0.3em
.downvote i
text-align: right
.upvote i
display: inline-block
width: 1em
margin: 0
.value
text-align: center
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', ' ', '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

View file

@ -270,6 +270,11 @@ input[type=submit]
background-color: $button-disabled-background-color
color: $button-disabled-text-color
&.discourage
border-color: transparent
background-color: transparent
color: $button-disabled-text-color
&:focus
border: 2px solid $text-color

View file

@ -15,6 +15,7 @@ body
min-height: 100%
body
background: $window-color
overflow-y: scroll
margin: 0
color: $text-color

View file

@ -65,7 +65,7 @@ $safety-unsafe = #F3985F
.social
margin-top: 1em
.score
.score-container
float: left
margin-right: 3em
.downvote i

75
client/html/comment.tpl Normal file
View file

@ -0,0 +1,75 @@
<div class='comment'>
<div class='avatar'>
<% if (ctx.comment.user.name && ctx.canViewUsers) { %>
<a href='/user/<%= ctx.comment.user.name %>'>
<% } %>
<%= ctx.makeThumbnail(ctx.comment.user.avatarUrl) %>
<% if (ctx.comment.user.name && ctx.canViewUsers) { %>
</a>
<% } %>
</div>
<div class='body'>
<header><!--
--><span class='nickname'><!--
--><% if (ctx.comment.user.name && ctx.canViewUsers) { %><!--
--><a href='/user/<%= ctx.comment.user.name %>'><!--
--><% } %><!--
--><%= ctx.comment.user.name %><!--
--><% if (ctx.comment.user.name && ctx.canViewUsers) { %><!--
--></a><!--
--><% } %><!--
--></span><!--
--><span class='date'><!--
--><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><!--
--></span><!--
--><span class='score-container'></span><!--
--><% if (ctx.canEditComment) { %><!--
--><a class='edit' href='#'><!--
--><i class='fa fa-pencil'></i> edit<!--
--></a><!--
--><% } %><!--
--><% if (ctx.canDeleteComment) { %><!--
--><a class='delete' href='#'><!--
--><i class='fa fa-remove'></i> delete<!--
--></a><!--
--><% } %><!--
--></header>
<div class='tabs'>
<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>

View file

@ -0,0 +1,6 @@
<div class='comments'>
<% if (ctx.canListComments && ctx.comments.length) { %>
<ul>
</ul>
<% } %>
</div>

15
client/html/fav.tpl Normal file
View file

@ -0,0 +1,15 @@
<% if (ctx.canFavorite) { %>
<% if (ctx.ownFavorite) { %>
<a class='remove-favorite' href='#'>
<i class='fa fa-heart'></i>
<% } else { %>
<a class='add-favorite' href='#'>
<i class='fa fa-heart-o'></i>
<% } %>
<% } else { %>
<a class='add-favorite inactive'>
<i class='fa fa-heart-o'></i>
<% } %>
<span class='vim-nav-hint'>add to favorites</span>
</a>
<span class='value'><%= ctx.favoriteCount %></span>

View file

@ -46,8 +46,6 @@
<div class='content'>
<div class='post-container'></div>
<section class='comments'>
<!-- TODO: comments -->
</section>
<div class='comments-container'></div>
</div>
</div>

View file

@ -38,53 +38,9 @@
</section>
<section class='social'>
<div class='score'>
<% if (ctx.canScorePosts) { %>
<a class='upvote' href='#'>
<% if (ctx.post.ownScore == 1) { %>
<i class='fa fa-thumbs-up'></i>
<% } else { %>
<i class='fa fa-thumbs-o-up'></i>
<% } %>
<span class='vim-nav-hint'>upvote</span>
<span class='vim-nav-hint'>like</span>
</a>
<% } else { %>
<a class='upvote inactive'>
<i class='fa fa-thumbs-o-up'></i>
</a>
<% } %>
<span class='value'><%= ctx.post.score %></span>
<% if (ctx.canScorePosts) { %>
<a class='downvote' href='#'>
<% if (ctx.post.ownScore == -1) { %>
<i class='fa fa-thumbs-down'></i>
<% } else { %>
<i class='fa fa-thumbs-o-down'></i>
<% } %>
<span class='vim-nav-hint'>downvote</span>
<span class='vim-nav-hint'>dislike</span>
</a>
<% } %>
</div>
<div class='score-container'></div>
<div class='fav'>
<% if (ctx.canFavoritePosts) { %>
<% if (ctx.post.ownFavorite) { %>
<a class='remove-favorite' href='#'>
<i class='fa fa-heart'></i>
<% } else { %>
<a class='add-favorite' href='#'>
<i class='fa fa-heart-o'></i>
<% } %>
<% } else { %>
<a class='add-favorite inactive'>
<i class='fa fa-heart-o'></i>
<% } %>
<span class='vim-nav-hint'>add to favorites</span>
</a>
<span class='value'><%= ctx.post.favoriteCount %></span>
</div>
<div class='fav-container'></div>
</section>
</article>

27
client/html/score.tpl Normal file
View file

@ -0,0 +1,27 @@
<% if (ctx.canScore) { %>
<a class='upvote' href='#'>
<% if (ctx.ownScore == 1) { %>
<i class='fa fa-thumbs-up'></i>
<% } else { %>
<i class='fa fa-thumbs-o-up'></i>
<% } %>
<span class='vim-nav-hint'>upvote</span>
<span class='vim-nav-hint'>like</span>
</a>
<% } else { %>
<a class='upvote inactive'>
<i class='fa fa-thumbs-o-up'></i>
</a>
<% } %>
<span class='value'><%= ctx.score %></span>
<% if (ctx.canScore) { %>
<a class='downvote' href='#'>
<% if (ctx.ownScore == -1) { %>
<i class='fa fa-thumbs-down'></i>
<% } else { %>
<i class='fa fa-thumbs-o-down'></i>
<% } %>
<span class='vim-nav-hint'>downvote</span>
<span class='vim-nav-hint'>dislike</span>
</a>
<% } %>

View file

@ -0,0 +1,185 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
class CommentControl {
constructor(hostNode, comment) {
this._hostNode = hostNode;
this._comment = comment;
this._template = views.getTemplate('comment');
this._scoreTemplate = views.getTemplate('score');
this.install();
}
install() {
const isLoggedIn = api.isLoggedIn(this._comment.user);
const infix = isLoggedIn ? 'own' : 'any';
const sourceNode = this._template({
comment: this._comment,
canViewUsers: api.hasPrivilege('users:view'),
canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
});
views.showView(
sourceNode.querySelector('.score-container'),
this._scoreTemplate({
score: this._comment.score,
ownScore: this._comment.ownScore,
canScore: api.hasPrivilege('comments:score'),
}));
const editButton = sourceNode.querySelector('.edit');
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(
'click', e => this._evtEditClick(e));
}
if (deleteButton) {
deleteButton.addEventListener(
'click', e => this._evtDeleteClick(e));
}
if (upvoteButton) {
upvoteButton.addEventListener(
'click',
e => this._evtScoreClick(
e, () => this._comment.ownScore === 1 ? 0 : 1));
}
if (downvoteButton) {
downvoteButton.addEventListener(
'click',
e => this._evtScoreClick(
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);
});
}
textareaNode.addEventListener('change', e => { this._growTextArea(); });
views.showView(this._hostNode, sourceNode);
}
_evtScoreClick(e, scoreGetter) {
e.preventDefault();
api.put(
'/comment/' + this._comment.id + '/score',
{score: scoreGetter()})
.then(
response => {
this._comment.score = parseInt(response.score);
this._comment.ownScore = parseInt(response.ownScore);
this.install();
}, response => {
window.alert(response.description);
});
}
_evtDeleteClick(e) {
e.preventDefault();
if (!window.confirm('Are you sure you want to delete this comment?')) {
return;
}
api.delete('/comment/' + this._comment.id)
.then(response => {
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;

View file

@ -0,0 +1,41 @@
'use strict';
const api = require('../api.js');
const views = require('../util/views.js');
const CommentControl = require('../controls/comment_control.js');
class CommentListControl {
constructor(hostNode, comments) {
this._hostNode = hostNode;
this._comments = comments;
this._template = views.getTemplate('comment-list');
this.install();
}
install() {
const sourceNode = this._template({
comments: this._comments,
canListComments: api.hasPrivilege('comments:list'),
});
views.showView(this._hostNode, sourceNode);
this._renderComments();
}
_renderComments() {
if (!this._comments.length) {
return;
}
const commentList = new DocumentFragment();
for (let comment of this._comments) {
const commentListItemNode = document.createElement('li');
new CommentControl(commentListItemNode, comment);
commentList.appendChild(commentListItemNode);
}
views.showView(this._hostNode.querySelector('ul'), commentList);
}
};
module.exports = CommentListControl;

View file

@ -10,6 +10,8 @@ class PostReadonlySidebarControl {
this._post = post;
this._postContentControl = postContentControl;
this._template = views.getTemplate('post-readonly-sidebar');
this._scoreTemplate = views.getTemplate('score');
this._favTemplate = views.getTemplate('fav');
this.install();
}
@ -20,10 +22,25 @@ class PostReadonlySidebarControl {
getTagCategory: this._getTagCategory,
getTagUsages: this._getTagUsages,
canListPosts: api.hasPrivilege('posts:list'),
canScorePosts: api.hasPrivilege('posts:score'),
canFavoritePosts: api.hasPrivilege('posts:favorite'),
canViewTags: api.hasPrivilege('tags:view'),
});
views.showView(
sourceNode.querySelector('.score-container'),
this._scoreTemplate({
score: this._post.score,
ownScore: this._post.ownScore,
canScore: api.hasPrivilege('posts:score'),
}));
views.showView(
sourceNode.querySelector('.fav-container'),
this._favTemplate({
favoriteCount: this._post.favoriteCount,
ownFavorite: this._post.ownFavorite,
canFavorite: api.hasPrivilege('posts:favorite'),
}));
const upvoteButton = sourceNode.querySelector('.upvote');
const downvoteButton = sourceNode.querySelector('.downvote')
const addFavButton = sourceNode.querySelector('.add-favorite')

View file

@ -1,6 +1,7 @@
'use strict';
require('./util/polyfill.js');
const misc = require('./util/misc.js');
const page = require('page');
const origPushState = page.Context.prototype.pushState;
@ -9,6 +10,20 @@ page.Context.prototype.pushState = function() {
origPushState.call(this);
};
page.cancel = function(ctx) {
prevContext = ctx;
ctx.pushState();
};
page.exit((ctx, next) => {
views.unlistenToMessages();
if (misc.confirmPageExit()) {
next();
} else {
page.cancel(ctx);
}
});
const mousetrap = require('mousetrap');
page(/.*/, (ctx, next) => {
mousetrap.reset();
@ -34,11 +49,6 @@ for (let controller of controllers) {
controller.registerRoutes();
}
page.exit((ctx, next) => {
views.unlistenToMessages();
next();
});
const api = require('./api.js');
Promise.all([tags.refreshExport(), api.loginFromCookies()])
.then(() => {

View file

@ -199,6 +199,27 @@ function unindent(callSite, ...args) {
return format(output);
}
function enableExitConfirmation() {
window.onbeforeunload = e => {
return 'Are you sure you want to leave? ' +
'Data you have entered may not be saved.';
};
}
function disableExitConfirmation() {
window.onbeforeunload = null;
}
function confirmPageExit() {
if (!window.onbeforeunload) {
return true;
}
if (window.confirm(window.onbeforeunload())) {
disableExitConfirmation();
return true;
}
}
module.exports = {
range: range,
formatSearchQuery: formatSearchQuery,
@ -208,4 +229,7 @@ module.exports = {
formatFileSize: formatFileSize,
formatMarkdown: formatMarkdown,
unindent: unindent,
enableExitConfirmation: enableExitConfirmation,
disableExitConfirmation: disableExitConfirmation,
confirmPageExit: confirmPageExit,
};

View file

@ -29,6 +29,10 @@ function makeFileSize(fileSize) {
return misc.formatFileSize(fileSize);
}
function makeMarkdown(text) {
return misc.formatMarkdown(text);
}
function makeRelativeTime(time) {
return makeNonVoidElement(
'time',
@ -202,7 +206,7 @@ function makeVoidElement(name, attributes) {
return `<${_serializeElement(name, attributes)}/>`;
}
function _messageHandler(target, message, className) {
function showMessage(target, message, className) {
if (!message) {
message = 'Unknown message';
}
@ -222,6 +226,18 @@ function _messageHandler(target, message, className) {
return true;
}
function showError(target, message) {
return showMessage(target, message, 'error');
}
function showSuccess(target, message) {
return showMessage(target, message, 'success');
}
function showInfo(target, message) {
return showMessage(target, message, 'info');
}
function unlistenToMessages() {
events.unlisten(events.Success);
events.unlisten(events.Error);
@ -234,7 +250,7 @@ function listenToMessages(target) {
events.listen(
eventType,
msg => {
return _messageHandler(target, msg, className);
return showMessage(target, msg, className);
});
};
listen(events.Success, 'success');
@ -269,6 +285,7 @@ function getTemplate(templatePath) {
Object.assign(ctx, {
makeRelativeTime: makeRelativeTime,
makeFileSize: makeFileSize,
makeMarkdown: makeMarkdown,
makeThumbnail: makeThumbnail,
makeRadio: makeRadio,
makeCheckbox: makeCheckbox,
@ -420,4 +437,7 @@ module.exports = {
slideDown: slideDown,
slideUp: slideUp,
monitorNodeRemoval: monitorNodeRemoval,
showError: showError,
showSuccess: showSuccess,
showInfo: showInfo,
};

View file

@ -10,6 +10,7 @@ const PostReadonlySidebarControl
= require('../controls/post_readonly_sidebar_control.js');
const PostEditSidebarControl
= require('../controls/post_edit_sidebar_control.js');
const CommentListControl = require('../controls/comment_list_control.js');
class PostView {
constructor() {
@ -63,6 +64,10 @@ class PostView {
this._postContentControl);
}
new CommentListControl(
postViewNode.querySelector('.comments-container'),
ctx.post.comments);
keyboard.bind('e', () => {
if (ctx.editMode) {
page.show('/post/' + ctx.post.id);