Major revision of comment system.
- Comments are stored in HTML as well as Markdown, so that conversion only happens when saving (rather than when viewing). - Added 'markdown' Jinja filter for easy development. This is quite a heavy filter, so it shouldn't be used (much) in production. - Added CLI command to update schemas on existing node types.
This commit is contained in:
@@ -4,24 +4,29 @@ $(document).on('click','body .comment-action-reply',function(e){
|
||||
e.preventDefault();
|
||||
|
||||
// container of the comment we are replying to
|
||||
var parentDiv = $(this).parent().parent();
|
||||
var parentDiv = $(this).closest('.comment-container');
|
||||
|
||||
// container of the first-level comment in the thread
|
||||
var parentDivFirst = $(this).parent().parent().prevAll('.is-first:first');
|
||||
var parentDivFirst = parentDiv.prevAll('.is-first:first');
|
||||
|
||||
// Get the id of the comment
|
||||
if (parentDiv.hasClass('is-reply')) {
|
||||
parentNodeId = parentDivFirst.data('node_id');
|
||||
parentNodeId = parentDivFirst.data('node-id');
|
||||
} else {
|
||||
parentNodeId = parentDiv.data('node_id');
|
||||
parentNodeId = parentDiv.data('node-id');
|
||||
}
|
||||
if (!parentNodeId) {
|
||||
if (console) console.log('No parent ID found on ', parentDiv.toArray(), parentDivFirst.toArray());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the textarea and set its parent_id data
|
||||
var commentField = document.getElementById('comment_field');
|
||||
commentField.setAttribute('data-parent_id', parentNodeId);
|
||||
commentField.dataset.parentId = parentNodeId;
|
||||
|
||||
// Start the comment field with @authorname:
|
||||
var replyAuthor = $(this).parent().parent().find('.comment-author:first span').html();
|
||||
var replyAuthor = parentDiv.find('.comment-author:first span').html();
|
||||
$(commentField).val("**@" + replyAuthor.slice(1, -1) + ":** ");
|
||||
|
||||
// Add class for styling
|
||||
@@ -107,3 +112,179 @@ $(document).on('click','body .comment-action-rating',function(e){
|
||||
$this.siblings('.comment-rating-value').text(rating);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches a comment, returns a promise object.
|
||||
*/
|
||||
function loadComment(comment_id, projection)
|
||||
{
|
||||
if (typeof comment_id === 'undefined') {
|
||||
console.log('Error, loadComment(', comment_id, ', ', projection, ') called.');
|
||||
return $.Deferred().reject();
|
||||
}
|
||||
|
||||
// Add required projections for the permission system to work.
|
||||
projection.node_type = 1;
|
||||
projection.project = 1;
|
||||
|
||||
var url = '/api/nodes/' + comment_id;
|
||||
return $.get({
|
||||
url: url,
|
||||
data: {projection: projection},
|
||||
cache: false, // user should be ensured the latest comment to edit.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function loadComments(commentsUrl)
|
||||
{
|
||||
return $.get(commentsUrl)
|
||||
.done(function(dataHtml) {
|
||||
// Update the DOM injecting the generate HTML into the page
|
||||
$('#comments-container').html(dataHtml);
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
statusBarSet('error', "Couldn't load comments. Error: " + xhr.responseText, 'pi-attention', 5000);
|
||||
$('#comments-container').html('<a id="comments-reload"><i class="pi-refresh"></i> Reload comments</a>');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows an error in the "Post Comment" button.
|
||||
*/
|
||||
function show_comment_button_error(msg) {
|
||||
var $button = $('.comment-action-submit');
|
||||
var $textarea = $('#comment_field');
|
||||
|
||||
$button.addClass('button-field-error');
|
||||
$textarea.addClass('field-error');
|
||||
$button.html(msg);
|
||||
|
||||
setTimeout(function(){
|
||||
$button.html('Post Comment');
|
||||
$button.removeClass('button-field-error');
|
||||
$textarea.removeClass('field-error');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows an error in the "edit comment" button.
|
||||
*/
|
||||
function show_comment_edit_button_error($button, msg) {
|
||||
var $textarea = $('#comment_field');
|
||||
|
||||
$button.addClass('error');
|
||||
$textarea.addClass('field-error');
|
||||
$button.html(msg);
|
||||
|
||||
setTimeout(function(){
|
||||
$button.html('<i class="pi-check"></i> save changes');
|
||||
$button.removeClass('button-field-error');
|
||||
$textarea.removeClass('field-error');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Switches the comment to either 'edit' or 'view' mode.
|
||||
*/
|
||||
function comment_mode(clicked_item, mode)
|
||||
{
|
||||
var $container = $(clicked_item).closest('.comment-container');
|
||||
var comment_id = $container.data('node-id');
|
||||
|
||||
var $edit_buttons = $container.find('.comment-action-edit');
|
||||
|
||||
if (mode == 'edit') {
|
||||
$edit_buttons.find('.edit_mode').hide();
|
||||
$edit_buttons.find('.edit_cancel').show();
|
||||
$edit_buttons.find('.edit_save').show();
|
||||
} else {
|
||||
$edit_buttons.find('.edit_mode').show();
|
||||
$edit_buttons.find('.edit_cancel').hide();
|
||||
$edit_buttons.find('.edit_save').hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return UI to normal, when cancelling or saving.
|
||||
*
|
||||
* clicked_item: save/cancel button.
|
||||
*
|
||||
* Returns a promise on the comment loading.
|
||||
*/
|
||||
function commentEditCancel(clicked_item) {
|
||||
var comment_container = $(clicked_item).closest('.comment-container');
|
||||
var comment_id = comment_container.data('node-id');
|
||||
|
||||
return loadComment(comment_id, {'properties.content': 1})
|
||||
.done(function(data) {
|
||||
var comment_raw = data['properties']['content'];
|
||||
var comment_html = convert(comment_raw);
|
||||
|
||||
comment_mode(clicked_item, 'view');
|
||||
comment_container.find('.comment-content')
|
||||
.removeClass('editing')
|
||||
.html(comment_html);
|
||||
comment_container.find('.comment-content-preview').html('').hide();
|
||||
})
|
||||
.fail(function(data) {
|
||||
if (console) console.log('Error fetching comment: ', xhr);
|
||||
statusBarSet('error', 'Error canceling.', 'pi-warning');
|
||||
});
|
||||
}
|
||||
|
||||
function save_comment(is_new_comment, $commentContainer)
|
||||
{
|
||||
var promise = $.Deferred();
|
||||
var commentField;
|
||||
var commentId;
|
||||
var parent_id;
|
||||
|
||||
// Get data from HTML, and validate it.
|
||||
if (is_new_comment)
|
||||
commentField = $('#comment_field');
|
||||
else {
|
||||
commentField = $commentContainer.find('textarea');
|
||||
commentId = $commentContainer.data('node-id');
|
||||
}
|
||||
|
||||
if (!commentField.length)
|
||||
return promise.reject("Unable to find comment field.");
|
||||
|
||||
if (is_new_comment) {
|
||||
parent_id = commentField.data('parent-id');
|
||||
if (!parent_id) {
|
||||
if (console) console.log("No parent ID found in comment field data.");
|
||||
return promise.reject("No parent ID!");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the comment itself.
|
||||
var comment = commentField.val();
|
||||
if (comment.length < 5) {
|
||||
if (comment.length == 0) promise.reject("Say something...");
|
||||
else promise.reject("Minimum 5 characters.");
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Notify callers of the fact that client-side validation has passed.
|
||||
promise.notify();
|
||||
|
||||
// Actually post the comment.
|
||||
if (is_new_comment) {
|
||||
$.post('/nodes/comments/create',
|
||||
{'content': comment, 'parent_id': parent_id})
|
||||
.fail(promise.reject)
|
||||
.done(function(data) { promise.resolve(data.node_id, comment); });
|
||||
} else {
|
||||
$.post('/nodes/comments/' + commentId,
|
||||
{'content': comment})
|
||||
.fail(promise.reject)
|
||||
.done(function(data) { promise.resolve(commentId, comment); });
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
42
src/scripts/tutti/6_jquery_extensions.js
Normal file
42
src/scripts/tutti/6_jquery_extensions.js
Normal file
@@ -0,0 +1,42 @@
|
||||
(function ( $ ) {
|
||||
$.fn.flashOnce = function() {
|
||||
var target = this;
|
||||
this
|
||||
.addClass('flash-on')
|
||||
.delay(1000) // this delay is linked to the transition in the flash-on CSS class.
|
||||
.queue(function() {
|
||||
target
|
||||
.removeClass('flash-on')
|
||||
.addClass('flash-off')
|
||||
.dequeue()
|
||||
;})
|
||||
.delay(1000) // this delay is just to clean up the flash-X classes.
|
||||
.queue(function() {
|
||||
target
|
||||
.removeClass('flash-on flash-off')
|
||||
.dequeue()
|
||||
;})
|
||||
;
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fades out the element, then erases its contents and shows the now-empty element again.
|
||||
*/
|
||||
$.fn.fadeOutAndClear = function(fade_speed) {
|
||||
var target = this;
|
||||
this
|
||||
.fadeOut(fade_speed, function() {
|
||||
target
|
||||
.html('')
|
||||
.show();
|
||||
});
|
||||
}
|
||||
|
||||
$.fn.scrollHere = function(scroll_duration_msec) {
|
||||
$('html, body').animate({
|
||||
scrollTop: this.offset().top
|
||||
}, scroll_duration_msec);
|
||||
}
|
||||
|
||||
}(jQuery));
|
@@ -577,3 +577,19 @@
|
||||
display: block
|
||||
max-width: 100%
|
||||
height: auto
|
||||
|
||||
|
||||
.flash-on
|
||||
background-color: lighten($color-success, 50%) !important
|
||||
border-color: lighten($color-success, 40%) !important
|
||||
color: $color-success !important
|
||||
text-shadow: 1px 1px 0 white
|
||||
transition: all .1s ease-in
|
||||
img
|
||||
transition: all .1s ease-in
|
||||
opacity: .8
|
||||
|
||||
.flash-off
|
||||
transition: all 1s ease-out
|
||||
img
|
||||
transition: all 1s ease-out
|
||||
|
@@ -53,26 +53,8 @@ script(type="text/javascript").
|
||||
}
|
||||
}
|
||||
|
||||
function loadComments(){
|
||||
var commentsUrl = "{{ url_for('nodes.comments_index', parent_id=node._id) }}";
|
||||
|
||||
$.get(commentsUrl, function(dataHtml) {
|
||||
})
|
||||
.done(function(dataHtml){
|
||||
// Update the DOM injecting the generate HTML into the page
|
||||
$('#comments-container').replaceWith(dataHtml);
|
||||
})
|
||||
.fail(function(e, data){
|
||||
statusBarSet('error', 'Couldn\'t load comments. Error: ' + data.errorThrown, 'pi-attention', 5000);
|
||||
$('#comments-container').html('<a id="comments-reload"><i class="pi-refresh"></i> Reload comments</a>');
|
||||
});
|
||||
}
|
||||
|
||||
loadComments();
|
||||
|
||||
$('body').on('click', '#comments-reload', function(){
|
||||
loadComments();
|
||||
});
|
||||
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node._id) }}";
|
||||
loadComments(commentsUrl);
|
||||
|
||||
{% if node.has_method('PUT') %}
|
||||
$('.project-mode-view').show();
|
||||
@@ -186,4 +168,3 @@ script(type="text/javascript").
|
||||
if (typeof $().tooltip != 'undefined'){
|
||||
$('[data-toggle="tooltip"]').tooltip({'delay' : {'show': 1250, 'hide': 250}});
|
||||
}
|
||||
|
||||
|
46
src/templates/nodes/custom/comment/_macros.jade
Normal file
46
src/templates/nodes/custom/comment/_macros.jade
Normal file
@@ -0,0 +1,46 @@
|
||||
| {%- macro render_comment(comment, is_reply) -%}
|
||||
.comment-container(
|
||||
id="{{ comment._id }}",
|
||||
data-node-id="{{ comment._id }}",
|
||||
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
|
||||
|
||||
.comment-header
|
||||
.comment-avatar
|
||||
img(src="{{ comment._user.email | gravatar }}")
|
||||
|
||||
.comment-author(class="{% if comment._is_own %}own{% endif %}")
|
||||
| {{ comment._user.full_name }}
|
||||
span.username ({{ comment._user.username }})
|
||||
|
||||
.comment-time {{ comment._created | pretty_date_time }} {% if comment._created != comment._updated %} (edited {{ comment._updated | pretty_date_time }}){% endif %}
|
||||
|
||||
.comment-content {{comment.properties.content_html | safe }}
|
||||
| {% if comment._is_own %}
|
||||
.comment-content-preview
|
||||
| {% endif %}
|
||||
|
||||
.comment-meta
|
||||
.comment-rating(
|
||||
class="{% if comment._current_user_rating is not none %}rated{% if comment._current_user_rating %}positive{% endif %}{% endif %}")
|
||||
.comment-rating-value(title="Number of likes") {{ rating }}
|
||||
| {% if not comment._is_own %}
|
||||
.comment-action-rating.up(title="Like comment")
|
||||
| {% endif %}
|
||||
|
||||
.comment-action-reply(title="Reply to this comment")
|
||||
span reply
|
||||
| {% if comment._is_own %}
|
||||
.comment-action-edit
|
||||
span.edit_mode(title="Edit comment") edit
|
||||
span.edit_save(title="Save comment")
|
||||
i.pi-check
|
||||
| save changes
|
||||
span.edit_cancel(title="Cancel changes")
|
||||
i.pi-cancel
|
||||
| cancel
|
||||
| {% endif %}
|
||||
|
||||
| {% for reply in comment['_replies']['_items'] %}
|
||||
| {{ render_comment(reply, True) }}
|
||||
| {% endfor %}
|
||||
| {%- endmacro -%}
|
199
src/templates/nodes/custom/comment/list_embed.jade
Normal file
199
src/templates/nodes/custom/comment/list_embed.jade
Normal file
@@ -0,0 +1,199 @@
|
||||
| {% import 'nodes/custom/comment/_macros.html' as macros %}
|
||||
#comments-container
|
||||
a(name="comments")
|
||||
|
||||
section#comments-list
|
||||
.comment-reply-container
|
||||
| {% if can_post_comments %}
|
||||
.comment-reply-avatar
|
||||
img(src="{{ current_user.gravatar }}")
|
||||
|
||||
.comment-reply-form
|
||||
|
||||
.comment-reply-field
|
||||
textarea(
|
||||
id="comment_field",
|
||||
data-parent-id="{{ node_id }}",
|
||||
placeholder="Join the conversation...",)
|
||||
|
||||
.comment-reply-meta
|
||||
.comment-details
|
||||
.comment-rules
|
||||
a(
|
||||
title="Markdown Supported"
|
||||
href="https://guides.github.com/features/mastering-markdown/")
|
||||
i.pi-markdown
|
||||
|
||||
.comment-author
|
||||
span.commenting-as commenting as
|
||||
span.author-name {{ current_user.full_name }}
|
||||
|
||||
button.comment-action-cancel.btn.btn-outline(
|
||||
type="button",
|
||||
title="Cancel")
|
||||
i.pi-cancel
|
||||
button.comment-action-submit.btn.btn-outline(
|
||||
id="comment_submit",
|
||||
type="button",
|
||||
title="Post Comment")
|
||||
| Post Comment
|
||||
span.hint (Ctrl+Enter)
|
||||
|
||||
.comment-reply-preview
|
||||
|
||||
| {% elif current_user.is_authenticated %}
|
||||
|
||||
| {# * User is authenticated, but has no 'POST' permission #}
|
||||
.comment-reply-form
|
||||
.comment-reply-field.sign-in
|
||||
textarea(
|
||||
disabled,
|
||||
id="comment_field",
|
||||
data-parent-id="{{ node_id }}",
|
||||
placeholder="")
|
||||
.sign-in
|
||||
| Join the conversation! <a href="https://store.blender.org/product/membership/">Subscribe to Blender Cloud now.</a>
|
||||
|
||||
| {% else %}
|
||||
| {# * User is not autenticated #}
|
||||
.comment-reply-form
|
||||
.comment-reply-field.sign-in
|
||||
textarea(
|
||||
disabled,
|
||||
id="comment_field",
|
||||
data-parent-id="{{ node_id }}",
|
||||
placeholder="")
|
||||
.sign-in
|
||||
a(href="{{ url_for('users.login') }}") Log in
|
||||
| to comment.
|
||||
|
||||
| {% endif %}
|
||||
|
||||
section#comments-list-header
|
||||
#comments-list-title
|
||||
| {% if comments['_meta']['total'] == 0 %}No{% else %}{{ comments['_meta']['total'] }}{% endif %} comment{{ comments['_meta']['total']|pluralize }}
|
||||
#comments-list-items
|
||||
| {% for comment in comments['_items'] %}
|
||||
| {{ macros.render_comment(comment, False) }}
|
||||
| {% endfor %}
|
||||
| {% block comment_scripts %}
|
||||
|
||||
script.
|
||||
/* Submit new comment */
|
||||
$('.comment-action-submit').click(function(e){
|
||||
var $button = $(this);
|
||||
|
||||
save_comment(true)
|
||||
.progress(function() {
|
||||
$button
|
||||
.addClass('submitting')
|
||||
.html('<i class="pi-spin spin"></i> Posting...');
|
||||
})
|
||||
.fail(function(xhr){
|
||||
if (typeof xhr === 'string') {
|
||||
show_comment_button_error(xhr);
|
||||
} else {
|
||||
// If it's not a string, we assume it's a jQuery XHR object.
|
||||
if (console) console.log('Error saving comment:', xhr.responseText);
|
||||
show_comment_button_error("Houston! Try again?");
|
||||
}
|
||||
})
|
||||
.done(function(comment_node_id) {
|
||||
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node_id) }}";
|
||||
loadComments(commentsUrl)
|
||||
.done(function() {
|
||||
$('#' + comment_node_id).scrollHere();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
/* Edit comment */
|
||||
|
||||
// Markdown convert as we type in the textarea
|
||||
$(document).on('keyup','body .comment-content textarea',function(e){
|
||||
var $textarea = $(this);
|
||||
var $container = $(this).parent();
|
||||
var $preview = $container.next();
|
||||
|
||||
// TODO: communicate with back-end to do the conversion,
|
||||
// rather than relying on our JS-converted Markdown.
|
||||
$preview.html(convert($textarea.val()));
|
||||
|
||||
// While we are at it, style if empty
|
||||
if (!$textarea.val()) {
|
||||
$container.addClass('empty');
|
||||
} else {
|
||||
$container.removeClass('empty');
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/* Enter edit mode */
|
||||
$(document).on('click','body .comment-action-edit span.edit_mode',function(){
|
||||
comment_mode(this, 'edit');
|
||||
|
||||
var parent_div = $(this).closest('.comment-container');
|
||||
var comment_id = parent_div.data('node-id');
|
||||
|
||||
var comment_content = parent_div.find('.comment-content');
|
||||
var height = comment_content.height();
|
||||
|
||||
loadComment(comment_id, {'properties.content': 1})
|
||||
.done(function(data) {
|
||||
var comment_raw = data['properties']['content'];
|
||||
comment_content.html($('<textarea>').text(comment_raw));
|
||||
comment_content
|
||||
.addClass('editing')
|
||||
.find('textarea')
|
||||
.height(height + 30)
|
||||
.focus()
|
||||
.trigger('keyup');
|
||||
comment_content.siblings('.comment-content-preview').show();
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
if (console) console.log('Error fetching comment: ', xhr);
|
||||
statusBarSet('error', 'Error ' + xhr.status + ' entering edit mode.', 'pi-warning');
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('click','body .comment-action-edit span.edit_cancel',function(e){
|
||||
commentEditCancel(this);
|
||||
});
|
||||
|
||||
/* Save edited comment */
|
||||
$(document).on('click','body .comment-action-edit span.edit_save',function(e){
|
||||
var $button = $(this);
|
||||
var $container = $button.closest('.comment-container');
|
||||
|
||||
save_comment(false, $container)
|
||||
.progress(function() {
|
||||
$button
|
||||
.addClass('submitting')
|
||||
.html('<i class="pi-spin spin"></i> Posting...');
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
if (typeof xhr === 'string') {
|
||||
show_comment_edit_button_error($button, xhr);
|
||||
} else {
|
||||
// If it's not a string, we assume it's a jQuery XHR object.
|
||||
if (console) console.log('Error saving comment:', xhr.responseText);
|
||||
show_comment_edit_button_error($button, "Houston! Try again?");
|
||||
}
|
||||
})
|
||||
.done(function(comment_id, comment) {
|
||||
commentEditCancel($button)
|
||||
.done(function() {
|
||||
// TODO: reload just this comment's HTML from the back-end,
|
||||
// rather than relying on our JS-converted Markdown.
|
||||
$container.find('.comment-content').html(convert(comment));
|
||||
$container.flashOnce();
|
||||
});
|
||||
|
||||
$button
|
||||
.html('<i class="pi-check"></i> save changes')
|
||||
.removeClass('saving');
|
||||
});
|
||||
});
|
||||
|
||||
| {% endblock %}
|
Reference in New Issue
Block a user