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:
2016-10-19 09:57:43 +02:00
parent eea934a86a
commit ea2be0f13d
14 changed files with 831 additions and 37 deletions

View File

@@ -6,6 +6,11 @@ node_type_comment = {
'content': { 'content': {
'type': 'string', 'type': 'string',
'minlength': 5, 'minlength': 5,
'required': True,
},
# The converted-to-HTML content.
'content_html': {
'type': 'string',
}, },
'status': { 'status': {
'type': 'string', 'type': 'string',

View File

@@ -1,6 +1,6 @@
node_type_group = { node_type_group = {
'name': 'group', 'name': 'group',
'description': 'Generic group node type edited', 'description': 'Folder node type',
'parent': ['group', 'project'], 'parent': ['group', 'project'],
'dyn_schema': { 'dyn_schema': {
# Used for sorting within the context of a group # Used for sorting within the context of a group

View File

@@ -8,6 +8,8 @@ import rsa.randnum
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from bson import ObjectId from bson import ObjectId
from flask import current_app, g, Blueprint, request from flask import current_app, g, Blueprint, request
import pillar.markdown
from pillar.api import file_storage from pillar.api import file_storage
from pillar.api.activities import activity_subscribe, activity_object_add from pillar.api.activities import activity_subscribe, activity_object_add
from pillar.api.utils.algolia import algolia_index_node_delete from pillar.api.utils.algolia import algolia_index_node_delete
@@ -26,6 +28,11 @@ def only_for_node_type_decorator(required_node_type_name):
If the node type is not of the required node type, returns None, If the node type is not of the required node type, returns None,
otherwise calls the wrapped function. otherwise calls the wrapped function.
>>> deco = only_for_node_type_decorator('comment')
>>> @deco
... def handle_comment(node): pass
""" """
def only_for_node_type(wrapped): def only_for_node_type(wrapped):
@@ -35,6 +42,7 @@ def only_for_node_type_decorator(required_node_type_name):
return return
return wrapped(node, *args, **kwargs) return wrapped(node, *args, **kwargs)
return wrapper return wrapper
only_for_node_type.__doc__ = "Decorator, immediately returns when " \ only_for_node_type.__doc__ = "Decorator, immediately returns when " \
@@ -415,8 +423,31 @@ def after_deleting_node(item):
item.get('_id'), ex) item.get('_id'), ex)
def setup_app(app, url_prefix): only_for_comments = only_for_node_type_decorator('comment')
@only_for_comments
def convert_markdown(node, original=None):
"""Converts comments from Markdown to HTML.
Always does this on save, even when the original Markdown hasn't changed,
because our Markdown -> HTML conversion rules might have.
"""
try:
content = node['properties']['content']
except KeyError:
node['properties']['content_html'] = ''
else:
node['properties']['content_html'] = pillar.markdown.markdown(content)
def nodes_convert_markdown(nodes):
for node in nodes:
convert_markdown(node)
def setup_app(app, url_prefix):
from . import patch from . import patch
patch.setup_app(app, url_prefix=url_prefix) patch.setup_app(app, url_prefix=url_prefix)
@@ -427,6 +458,7 @@ def setup_app(app, url_prefix):
app.on_fetched_resource_nodes += resource_parse_attachments app.on_fetched_resource_nodes += resource_parse_attachments
app.on_replace_nodes += before_replacing_node app.on_replace_nodes += before_replacing_node
app.on_replace_nodes += convert_markdown
app.on_replace_nodes += deduct_content_type app.on_replace_nodes += deduct_content_type
app.on_replace_nodes += node_set_default_picture app.on_replace_nodes += node_set_default_picture
app.on_replaced_nodes += after_replacing_node app.on_replaced_nodes += after_replacing_node
@@ -434,6 +466,7 @@ def setup_app(app, url_prefix):
app.on_insert_nodes += before_inserting_nodes app.on_insert_nodes += before_inserting_nodes
app.on_insert_nodes += nodes_deduct_content_type app.on_insert_nodes += nodes_deduct_content_type
app.on_insert_nodes += nodes_set_default_picture app.on_insert_nodes += nodes_set_default_picture
app.on_insert_nodes += nodes_convert_markdown
app.on_inserted_nodes += after_inserting_nodes app.on_inserted_nodes += after_inserting_nodes
app.on_deleted_item_nodes += after_deleting_node app.on_deleted_item_nodes += after_deleting_node

View File

@@ -5,9 +5,12 @@ Run commands with 'flask <command>'
from __future__ import print_function, division from __future__ import print_function, division
import copy
import logging import logging
from bson.objectid import ObjectId, InvalidId from bson.objectid import ObjectId, InvalidId
from eve.methods.put import put_internal
from flask import current_app from flask import current_app
from flask_script import Manager from flask_script import Manager
@@ -502,3 +505,125 @@ def move_group_node_project(node_uuid, dest_proj_url, force=False, skip_gcs=Fals
mover.change_project(node, dest_proj) mover.change_project(node, dest_proj)
log.info('Done moving.') log.info('Done moving.')
@manager.command
@manager.option('-p', '--project', dest='proj_url', nargs='?',
help='Project URL')
@manager.option('-a', '--all', dest='all_projects', action='store_true', default=False,
help='Replace on all projects.')
def replace_pillar_node_type_schemas(proj_url=None, all_projects=False):
"""Replaces the project's node type schemas with the standard Pillar ones.
Non-standard node types are left alone.
"""
if bool(proj_url) == all_projects:
log.error('Use either --project or --all.')
return 1
from pillar.api.utils.authentication import force_cli_user
force_cli_user()
from pillar.api.node_types.asset import node_type_asset
from pillar.api.node_types.blog import node_type_blog
from pillar.api.node_types.comment import node_type_comment
from pillar.api.node_types.group import node_type_group
from pillar.api.node_types.group_hdri import node_type_group_hdri
from pillar.api.node_types.group_texture import node_type_group_texture
from pillar.api.node_types.hdri import node_type_hdri
from pillar.api.node_types.page import node_type_page
from pillar.api.node_types.post import node_type_post
from pillar.api.node_types.storage import node_type_storage
from pillar.api.node_types.text import node_type_text
from pillar.api.node_types.texture import node_type_texture
from pillar.api.utils import remove_private_keys
node_types = [node_type_asset, node_type_blog, node_type_comment, node_type_group,
node_type_group_hdri, node_type_group_texture, node_type_hdri, node_type_page,
node_type_post, node_type_storage, node_type_text, node_type_texture]
name_to_nt = {nt['name']: nt for nt in node_types}
projects_collection = current_app.db()['projects']
def handle_project(project):
log.info('Handling project %s', project['url'])
for proj_nt in project['node_types']:
nt_name = proj_nt['name']
try:
pillar_nt = name_to_nt[nt_name]
except KeyError:
log.info(' - skipping non-standard node type "%s"', nt_name)
continue
log.info(' - replacing schema on node type "%s"', nt_name)
# This leaves node type keys intact that aren't in Pillar's node_type_xxx definitions,
# such as permissions.
proj_nt.update(copy.deepcopy(pillar_nt))
# Use Eve to PUT, so we have schema checking.
db_proj = remove_private_keys(project)
r, _, _, status = put_internal('projects', db_proj, _id=project['_id'])
if status != 200:
log.error('Error %i storing altered project %s: %s', status, project['_id'], r)
return 4
log.info('Project saved succesfully.')
if all_projects:
for project in projects_collection.find():
handle_project(project)
return
project = projects_collection.find_one({'url': proj_url})
if not project:
log.error('Project url=%s not found', proj_url)
return 3
handle_project(project)
@manager.command
def remarkdown_comments():
"""Retranslates all Markdown to HTML for all comment nodes.
"""
from pillar.api.nodes import convert_markdown
from pprint import pformat
nodes_collection = current_app.db()['nodes']
comments = nodes_collection.find({'node_type': 'comment'},
projection={'properties.content': 1,
'node_type': 1})
updated = identical = skipped = errors = 0
for node in comments:
convert_markdown(node)
node_id = node['_id']
try:
content_html = node['properties']['content_html']
except KeyError:
log.warning('Node %s has no content_html', node_id)
skipped += 1
continue
result = nodes_collection.update_one(
{'_id': node_id},
{'$set': {'properties.content_html': content_html}}
)
if result.matched_count != 1:
log.error('Unable to update node %s', node_id)
errors += 1
continue
if result.modified_count:
updated += 1
else:
identical += 1
log.info('updated : %i', updated)
log.info('identical: %i', identical)
log.info('skipped : %i', skipped)
log.info('errors : %i', errors)

44
pillar/markdown.py Normal file
View File

@@ -0,0 +1,44 @@
"""Bleached Markdown functionality.
This is for user-generated stuff, like comments.
"""
from __future__ import absolute_import
import bleach
import markdown as _markdown
ALLOWED_TAGS = [
'a',
'abbr',
'acronym',
'b', 'strong',
'i', 'em',
'blockquote',
'code',
'li', 'ol', 'ul',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p',
'img',
]
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'target'],
'abbr': ['title'],
'acronym': ['title'],
'img': ['src', 'alt', 'width', 'height', 'title'],
'*': ['style'],
}
ALLOWED_STYLES = [
'color', 'font-weight', 'background-color',
]
def markdown(s):
tainted_html = _markdown.markdown(s)
safe_html = bleach.clean(tainted_html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
styles=ALLOWED_STYLES)
return safe_html

View File

@@ -3,9 +3,12 @@
from __future__ import absolute_import from __future__ import absolute_import
import jinja2.filters import jinja2.filters
import jinja2.utils
import pillar.api.utils
from pillar.web.utils import pretty_date from pillar.web.utils import pretty_date
from pillar.web.nodes.routes import url_for_node from pillar.web.nodes.routes import url_for_node
import pillar.markdown
def format_pretty_date(d): def format_pretty_date(d):
@@ -37,9 +40,64 @@ def do_hide_none(s):
return s return s
# Source: Django, django/template/defaultfilters.py
def do_pluralize(value, arg='s'):
"""
Returns a plural suffix if the value is not 1. By default, 's' is used as
the suffix:
* If value is 0, vote{{ value|pluralize }} displays "0 votes".
* If value is 1, vote{{ value|pluralize }} displays "1 vote".
* If value is 2, vote{{ value|pluralize }} displays "2 votes".
If an argument is provided, that string is used instead:
* If value is 0, class{{ value|pluralize:"es" }} displays "0 classes".
* If value is 1, class{{ value|pluralize:"es" }} displays "1 class".
* If value is 2, class{{ value|pluralize:"es" }} displays "2 classes".
If the provided argument contains a comma, the text before the comma is
used for the singular case and the text after the comma is used for the
plural case:
* If value is 0, cand{{ value|pluralize:"y,ies" }} displays "0 candies".
* If value is 1, cand{{ value|pluralize:"y,ies" }} displays "1 candy".
* If value is 2, cand{{ value|pluralize:"y,ies" }} displays "2 candies".
"""
if ',' not in arg:
arg = ',' + arg
bits = arg.split(',')
if len(bits) > 2:
return ''
singular_suffix, plural_suffix = bits[:2]
try:
if float(value) != 1:
return plural_suffix
except ValueError: # Invalid string that's not a number.
pass
except TypeError: # Value isn't a string or a number; maybe it's a list?
try:
if len(value) != 1:
return plural_suffix
except TypeError: # len() of unsized object.
pass
return singular_suffix
def do_markdown(s):
# FIXME: get rid of this filter altogether and cache HTML of comments.
safe_html = pillar.markdown.markdown(s)
return jinja2.utils.Markup(safe_html)
def setup_jinja_env(jinja_env): def setup_jinja_env(jinja_env):
jinja_env.filters['pretty_date'] = format_pretty_date jinja_env.filters['pretty_date'] = format_pretty_date
jinja_env.filters['pretty_date_time'] = format_pretty_date_time jinja_env.filters['pretty_date_time'] = format_pretty_date_time
jinja_env.filters['undertitle'] = format_undertitle jinja_env.filters['undertitle'] = format_undertitle
jinja_env.filters['hide_none'] = do_hide_none jinja_env.filters['hide_none'] = do_hide_none
jinja_env.filters['pluralize'] = do_pluralize
jinja_env.filters['gravatar'] = pillar.api.utils.gravatar
jinja_env.filters['markdown'] = do_markdown
jinja_env.globals['url_for_node'] = url_for_node jinja_env.globals['url_for_node'] = url_for_node

View File

@@ -1,4 +1,7 @@
import logging import logging
import warnings
import flask
from flask import current_app from flask import current_app
from flask import request from flask import request
from flask import jsonify from flask import jsonify
@@ -7,6 +10,8 @@ from flask_login import login_required, current_user
from pillarsdk import Node from pillarsdk import Node
from pillarsdk import Project from pillarsdk import Project
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from pillar.web import subquery
from pillar.web.nodes.routes import blueprint from pillar.web.nodes.routes import blueprint
from pillar.web.utils import gravatar from pillar.web.utils import gravatar
from pillar.web.utils import pretty_date from pillar.web.utils import pretty_date
@@ -20,10 +25,22 @@ log = logging.getLogger(__name__)
def comments_create(): def comments_create():
content = request.form['content'] content = request.form['content']
parent_id = request.form.get('parent_id') parent_id = request.form.get('parent_id')
if not parent_id:
log.warning('User %s tried to create comment without parent_id', current_user.objectid)
raise wz_exceptions.UnprocessableEntity()
api = system_util.pillar_api() api = system_util.pillar_api()
parent_node = Node.find(parent_id, api=api) parent_node = Node.find(parent_id, api=api)
if not parent_node:
log.warning('Unable to create comment for user %s, parent node %r not found',
current_user.objectid, parent_id)
raise wz_exceptions.UnprocessableEntity()
node_asset_props = dict( log.warning('Creating comment for user %s on parent node %r',
current_user.objectid, parent_id)
comment_props = dict(
project=parent_node.project, project=parent_node.project,
name='Comment', name='Comment',
user=current_user.objectid, user=current_user.objectid,
@@ -36,20 +53,18 @@ def comments_create():
rating_negative=0)) rating_negative=0))
if parent_id: if parent_id:
node_asset_props['parent'] = parent_id comment_props['parent'] = parent_id
# Get the parent node and check if it's a comment. In which case we flag # Get the parent node and check if it's a comment. In which case we flag
# the current comment as a reply. # the current comment as a reply.
parent_node = Node.find(parent_id, api=api) parent_node = Node.find(parent_id, api=api)
if parent_node.node_type == 'comment': if parent_node.node_type == 'comment':
node_asset_props['properties']['is_reply'] = True comment_props['properties']['is_reply'] = True
node_asset = Node(node_asset_props) comment = Node(comment_props)
node_asset.create(api=api) comment.create(api=api)
return jsonify( return jsonify({'node_id': comment._id}), 201
asset_id=node_asset._id,
content=node_asset.properties.content)
@blueprint.route('/comments/<string(length=24):comment_id>', methods=['POST']) @blueprint.route('/comments/<string(length=24):comment_id>', methods=['POST'])
@@ -119,6 +134,8 @@ def format_comment(comment, is_reply=False, is_team=False, replies=None):
@blueprint.route("/comments/") @blueprint.route("/comments/")
def comments_index(): def comments_index():
warnings.warn('comments_index() is deprecated in favour of comments_for_node()')
parent_id = request.args.get('parent_id') parent_id = request.args.get('parent_id')
# Get data only if we format it # Get data only if we format it
api = system_util.pillar_api() api = system_util.pillar_api()
@@ -152,6 +169,50 @@ def comments_index():
return return_content return return_content
@blueprint.route('/<string(length=24):node_id>/comments')
def comments_for_node(node_id):
"""Shows the comments attached to the given node."""
api = system_util.pillar_api()
node = Node.find(node_id, api=api)
project = Project({'_id': node.project})
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
# Query for all children, i.e. comments on the node.
comments = Node.all({
'where': {'node_type': 'comment', 'parent': node_id},
}, api=api)
def enrich(some_comment):
some_comment['_user'] = subquery.get_user_info(some_comment['user'])
some_comment['_is_own'] = some_comment['user'] == current_user.objectid
some_comment['_current_user_rating'] = None # tri-state boolean
if current_user.is_authenticated:
for rating in comment.properties.ratings or ():
if rating.user != current_user.objectid:
continue
some_comment['_current_user_rating'] = rating.is_positive
for comment in comments['_items']:
# Query for all grandchildren, i.e. replies to comments on the node.
comment['_replies'] = Node.all({
'where': {'node_type': 'comment', 'parent': comment['_id']},
}, api=api)
enrich(comment)
for reply in comment['_replies']['_items']:
enrich(reply)
# Data will be requested via javascript
return render_template('nodes/custom/comment/list_embed.html',
node_id=node_id,
comments=comments,
can_post_comments=can_post_comments)
@blueprint.route("/comments/<comment_id>/rate/<operation>", methods=['POST']) @blueprint.route("/comments/<comment_id>/rate/<operation>", methods=['POST'])
@login_required @login_required
def comments_rate(comment_id, operation): def comments_rate(comment_id, operation):

View File

@@ -5,6 +5,7 @@ algoliasearch==1.8.0
bcrypt==2.0.0 bcrypt==2.0.0
blinker==1.4 blinker==1.4
bugsnag==2.3.1 bugsnag==2.3.1
bleach==1.4.3
Cerberus==0.9.2 Cerberus==0.9.2
Eve==0.6.3 Eve==0.6.3
Events==0.2.1 Events==0.2.1
@@ -19,6 +20,7 @@ google-apitools==0.4.11
httplib2==0.9.2 httplib2==0.9.2
idna==2.0 idna==2.0
MarkupSafe==0.23 MarkupSafe==0.23
markdown==2.6.7
ndg-httpsclient==0.4.0 ndg-httpsclient==0.4.0
Pillow==2.8.1 Pillow==2.8.1
pycparser==2.14 pycparser==2.14
@@ -48,6 +50,7 @@ cookies==2.2.1
cryptography==1.3.1 cryptography==1.3.1
enum34==1.1.3 enum34==1.1.3
funcsigs==1.0.1 funcsigs==1.0.1
html5lib==0.9999999
googleapis-common-protos==1.1.0 googleapis-common-protos==1.1.0
ipaddress==1.0.16 ipaddress==1.0.16
itsdangerous==0.24 itsdangerous==0.24

View File

@@ -4,24 +4,29 @@ $(document).on('click','body .comment-action-reply',function(e){
e.preventDefault(); e.preventDefault();
// container of the comment we are replying to // 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 // 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 // Get the id of the comment
if (parentDiv.hasClass('is-reply')) { if (parentDiv.hasClass('is-reply')) {
parentNodeId = parentDivFirst.data('node_id'); parentNodeId = parentDivFirst.data('node-id');
} else { } 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 // Get the textarea and set its parent_id data
var commentField = document.getElementById('comment_field'); var commentField = document.getElementById('comment_field');
commentField.setAttribute('data-parent_id', parentNodeId); commentField.dataset.parentId = parentNodeId;
// Start the comment field with @authorname: // 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) + ":** "); $(commentField).val("**@" + replyAuthor.slice(1, -1) + ":** ");
// Add class for styling // Add class for styling
@@ -107,3 +112,179 @@ $(document).on('click','body .comment-action-rating',function(e){
$this.siblings('.comment-rating-value').text(rating); $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;
}

View 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));

View File

@@ -577,3 +577,19 @@
display: block display: block
max-width: 100% max-width: 100%
height: auto 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

View File

@@ -53,26 +53,8 @@ script(type="text/javascript").
} }
} }
function loadComments(){ var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node._id) }}";
var commentsUrl = "{{ url_for('nodes.comments_index', parent_id=node._id) }}"; loadComments(commentsUrl);
$.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();
});
{% if node.has_method('PUT') %} {% if node.has_method('PUT') %}
$('.project-mode-view').show(); $('.project-mode-view').show();
@@ -186,4 +168,3 @@ script(type="text/javascript").
if (typeof $().tooltip != 'undefined'){ if (typeof $().tooltip != 'undefined'){
$('[data-toggle="tooltip"]').tooltip({'delay' : {'show': 1250, 'hide': 250}}); $('[data-toggle="tooltip"]').tooltip({'delay' : {'show': 1250, 'hide': 250}});
} }

View 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 -%}

View 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!&nbsp;<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 %}