diff --git a/client/css/colors.styl b/client/css/colors.styl
index b4b27560..1a5996ef 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 00000000..a3d52809
--- /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 62c2119c..f62b1d12 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 af7dff9e..699be1e4 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 fbde8116..877f0015 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 00000000..aac63468
--- /dev/null
+++ b/client/html/comment.tpl
@@ -0,0 +1,75 @@
+
diff --git a/client/html/comment_list.tpl b/client/html/comment_list.tpl
new file mode 100644
index 00000000..5e334585
--- /dev/null
+++ b/client/html/comment_list.tpl
@@ -0,0 +1,6 @@
+
diff --git a/client/html/fav.tpl b/client/html/fav.tpl
new file mode 100644
index 00000000..28426e2b
--- /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 00757774..39bf9d64 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 bd034756..0922e26d 100644
--- a/client/html/post_readonly_sidebar.tpl
+++ b/client/html/post_readonly_sidebar.tpl
@@ -38,53 +38,9 @@
diff --git a/client/html/score.tpl b/client/html/score.tpl
new file mode 100644
index 00000000..23e22199
--- /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 00000000..5ca252a3
--- /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 00000000..d78a6ad0
--- /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 f8d523ba..4ae8f64c 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 1636e96d..7afb1e05 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 68af2d8f..54c07315 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 940d035a..9ad5775c 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 f8e25df8..0c7b5418 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);
+
+ <% } %> +