From 7e8a9a0948bb0f778eb162455276c2a806ece6ff Mon Sep 17 00:00:00 2001 From: rr- Date: Sat, 11 Jun 2016 17:41:28 +0200 Subject: [PATCH] client/comments: add comment list view for post --- client/css/colors.styl | 1 + client/css/comments.styl | 136 +++++++++++++ client/css/forms.styl | 5 + client/css/main.styl | 1 + client/css/posts.styl | 2 +- client/html/comment.tpl | 75 +++++++ client/html/comment_list.tpl | 6 + client/html/fav.tpl | 15 ++ client/html/post.tpl | 4 +- client/html/post_readonly_sidebar.tpl | 48 +---- client/html/score.tpl | 27 +++ client/js/controls/comment_control.js | 185 ++++++++++++++++++ client/js/controls/comment_list_control.js | 41 ++++ .../controls/post_readonly_sidebar_control.js | 21 +- client/js/main.js | 20 +- client/js/util/misc.js | 24 +++ client/js/util/views.js | 24 ++- client/js/views/post_view.js | 5 + 18 files changed, 581 insertions(+), 59 deletions(-) create mode 100644 client/css/comments.styl create mode 100644 client/html/comment.tpl create mode 100644 client/html/comment_list.tpl create mode 100644 client/html/fav.tpl create mode 100644 client/html/score.tpl create mode 100644 client/js/controls/comment_control.js create mode 100644 client/js/controls/comment_list_control.js diff --git a/client/css/colors.styl b/client/css/colors.styl index b4b2756..1a5996e 100644 --- a/client/css/colors.styl +++ b/client/css/colors.styl @@ -1,4 +1,5 @@ $main-color = #24AADD +$window-color = white $top-nav-color = #F5F5F5 $text-color = #111 $inactive-link-color = #888 diff --git a/client/css/comments.styl b/client/css/comments.styl new file mode 100644 index 0000000..a3d5280 --- /dev/null +++ b/client/css/comments.styl @@ -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', '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 diff --git a/client/css/forms.styl b/client/css/forms.styl index 62c2119..f62b1d1 100644 --- a/client/css/forms.styl +++ b/client/css/forms.styl @@ -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 diff --git a/client/css/main.styl b/client/css/main.styl index af7dff9..699be1e 100644 --- a/client/css/main.styl +++ b/client/css/main.styl @@ -15,6 +15,7 @@ body min-height: 100% body + background: $window-color overflow-y: scroll margin: 0 color: $text-color diff --git a/client/css/posts.styl b/client/css/posts.styl index fbde811..877f001 100644 --- a/client/css/posts.styl +++ b/client/css/posts.styl @@ -65,7 +65,7 @@ $safety-unsafe = #F3985F .social margin-top: 1em - .score + .score-container float: left margin-right: 3em .downvote i diff --git a/client/html/comment.tpl b/client/html/comment.tpl new file mode 100644 index 0000000..aac6346 --- /dev/null +++ b/client/html/comment.tpl @@ -0,0 +1,75 @@ +
+ + +
+
<% if (ctx.comment.user.name && ctx.canViewUsers) { %><% } %><%= ctx.comment.user.name %><% if (ctx.comment.user.name && ctx.canViewUsers) { %><% } %><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><% if (ctx.canEditComment) { %> edit<% } %><% if (ctx.canDeleteComment) { %> delete<% } %>
+ +
+
+
+
+
<%= ctx.makeMarkdown(ctx.comment.text) %>
+
+ +
+ +
+
+ + + + +
+ +
+
+
+
diff --git a/client/html/comment_list.tpl b/client/html/comment_list.tpl new file mode 100644 index 0000000..5e33458 --- /dev/null +++ b/client/html/comment_list.tpl @@ -0,0 +1,6 @@ +
+ <% if (ctx.canListComments && ctx.comments.length) { %> + + <% } %> +
diff --git a/client/html/fav.tpl b/client/html/fav.tpl new file mode 100644 index 0000000..28426e2 --- /dev/null +++ b/client/html/fav.tpl @@ -0,0 +1,15 @@ +<% if (ctx.canFavorite) { %> + <% if (ctx.ownFavorite) { %> + + + <% } else { %> + + + <% } %> +<% } else { %> + + +<% } %> + add to favorites + +<%= ctx.favoriteCount %> diff --git a/client/html/post.tpl b/client/html/post.tpl index 0075777..39bf9d6 100644 --- a/client/html/post.tpl +++ b/client/html/post.tpl @@ -46,8 +46,6 @@
-
- -
+
diff --git a/client/html/post_readonly_sidebar.tpl b/client/html/post_readonly_sidebar.tpl index bd03475..0922e26 100644 --- a/client/html/post_readonly_sidebar.tpl +++ b/client/html/post_readonly_sidebar.tpl @@ -38,53 +38,9 @@
-
- <% if (ctx.canScorePosts) { %> - - <% if (ctx.post.ownScore == 1) { %> - - <% } else { %> - - <% } %> - upvote - like - - <% } else { %> - - - - <% } %> - <%= ctx.post.score %> - <% if (ctx.canScorePosts) { %> - - <% if (ctx.post.ownScore == -1) { %> - - <% } else { %> - - <% } %> - downvote - dislike - - <% } %> -
+
-
- <% if (ctx.canFavoritePosts) { %> - <% if (ctx.post.ownFavorite) { %> - - - <% } else { %> - - - <% } %> - <% } else { %> - - - <% } %> - add to favorites - - <%= ctx.post.favoriteCount %> -
+
diff --git a/client/html/score.tpl b/client/html/score.tpl new file mode 100644 index 0000000..23e2219 --- /dev/null +++ b/client/html/score.tpl @@ -0,0 +1,27 @@ +<% if (ctx.canScore) { %> + + <% if (ctx.ownScore == 1) { %> + + <% } else { %> + + <% } %> + upvote + like + +<% } else { %> + + + +<% } %> +<%= ctx.score %> +<% if (ctx.canScore) { %> + + <% if (ctx.ownScore == -1) { %> + + <% } else { %> + + <% } %> + downvote + dislike + +<% } %> diff --git a/client/js/controls/comment_control.js b/client/js/controls/comment_control.js new file mode 100644 index 0000000..5ca252a --- /dev/null +++ b/client/js/controls/comment_control.js @@ -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; diff --git a/client/js/controls/comment_list_control.js b/client/js/controls/comment_list_control.js new file mode 100644 index 0000000..d78a6ad --- /dev/null +++ b/client/js/controls/comment_list_control.js @@ -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; diff --git a/client/js/controls/post_readonly_sidebar_control.js b/client/js/controls/post_readonly_sidebar_control.js index f8d523b..4ae8f64 100644 --- a/client/js/controls/post_readonly_sidebar_control.js +++ b/client/js/controls/post_readonly_sidebar_control.js @@ -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') diff --git a/client/js/main.js b/client/js/main.js index 1636e96..7afb1e0 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -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(() => { diff --git a/client/js/util/misc.js b/client/js/util/misc.js index 68af2d8..54c0731 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -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, }; diff --git a/client/js/util/views.js b/client/js/util/views.js index 940d035..9ad5775 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -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, }; diff --git a/client/js/views/post_view.js b/client/js/views/post_view.js index f8e25df..0c7b541 100644 --- a/client/js/views/post_view.js +++ b/client/js/views/post_view.js @@ -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);