From ea2be0f13d03f108f869c78b5a1d31d3b7336c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Wed, 19 Oct 2016 09:57:43 +0200 Subject: [PATCH] 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. --- pillar/api/node_types/comment.py | 5 + pillar/api/node_types/group.py | 2 +- pillar/api/nodes/__init__.py | 35 ++- pillar/cli.py | 125 +++++++++++ pillar/markdown.py | 44 ++++ pillar/web/jinja.py | 58 +++++ pillar/web/nodes/custom/comments.py | 77 ++++++- requirements.txt | 3 + src/scripts/tutti/2_comments.js | 193 ++++++++++++++++- src/scripts/tutti/6_jquery_extensions.js | 42 ++++ src/styles/_utils.sass | 16 ++ src/templates/nodes/custom/_scripts.jade | 23 +- .../nodes/custom/comment/_macros.jade | 46 ++++ .../nodes/custom/comment/list_embed.jade | 199 ++++++++++++++++++ 14 files changed, 831 insertions(+), 37 deletions(-) create mode 100644 pillar/markdown.py create mode 100644 src/scripts/tutti/6_jquery_extensions.js create mode 100644 src/templates/nodes/custom/comment/_macros.jade create mode 100644 src/templates/nodes/custom/comment/list_embed.jade diff --git a/pillar/api/node_types/comment.py b/pillar/api/node_types/comment.py index 808f79fb..adbd25e0 100644 --- a/pillar/api/node_types/comment.py +++ b/pillar/api/node_types/comment.py @@ -6,6 +6,11 @@ node_type_comment = { 'content': { 'type': 'string', 'minlength': 5, + 'required': True, + }, + # The converted-to-HTML content. + 'content_html': { + 'type': 'string', }, 'status': { 'type': 'string', diff --git a/pillar/api/node_types/group.py b/pillar/api/node_types/group.py index 40768c0a..1f71eecb 100644 --- a/pillar/api/node_types/group.py +++ b/pillar/api/node_types/group.py @@ -1,6 +1,6 @@ node_type_group = { 'name': 'group', - 'description': 'Generic group node type edited', + 'description': 'Folder node type', 'parent': ['group', 'project'], 'dyn_schema': { # Used for sorting within the context of a group diff --git a/pillar/api/nodes/__init__.py b/pillar/api/nodes/__init__.py index 4b7a8703..4648a40a 100644 --- a/pillar/api/nodes/__init__.py +++ b/pillar/api/nodes/__init__.py @@ -8,6 +8,8 @@ import rsa.randnum import werkzeug.exceptions as wz_exceptions from bson import ObjectId from flask import current_app, g, Blueprint, request + +import pillar.markdown from pillar.api import file_storage from pillar.api.activities import activity_subscribe, activity_object_add 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, otherwise calls the wrapped function. + + >>> deco = only_for_node_type_decorator('comment') + >>> @deco + ... def handle_comment(node): pass + """ def only_for_node_type(wrapped): @@ -35,6 +42,7 @@ def only_for_node_type_decorator(required_node_type_name): return return wrapped(node, *args, **kwargs) + return wrapper only_for_node_type.__doc__ = "Decorator, immediately returns when " \ @@ -415,8 +423,31 @@ def after_deleting_node(item): 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 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_replace_nodes += before_replacing_node + app.on_replace_nodes += convert_markdown app.on_replace_nodes += deduct_content_type app.on_replace_nodes += node_set_default_picture 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 += nodes_deduct_content_type app.on_insert_nodes += nodes_set_default_picture + app.on_insert_nodes += nodes_convert_markdown app.on_inserted_nodes += after_inserting_nodes app.on_deleted_item_nodes += after_deleting_node diff --git a/pillar/cli.py b/pillar/cli.py index 260cf50d..fe669143 100644 --- a/pillar/cli.py +++ b/pillar/cli.py @@ -5,9 +5,12 @@ Run commands with 'flask ' from __future__ import print_function, division +import copy import logging from bson.objectid import ObjectId, InvalidId +from eve.methods.put import put_internal + from flask import current_app 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) 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) diff --git a/pillar/markdown.py b/pillar/markdown.py new file mode 100644 index 00000000..4da7d22f --- /dev/null +++ b/pillar/markdown.py @@ -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 diff --git a/pillar/web/jinja.py b/pillar/web/jinja.py index a342fe88..520ae373 100644 --- a/pillar/web/jinja.py +++ b/pillar/web/jinja.py @@ -3,9 +3,12 @@ from __future__ import absolute_import import jinja2.filters +import jinja2.utils +import pillar.api.utils from pillar.web.utils import pretty_date from pillar.web.nodes.routes import url_for_node +import pillar.markdown def format_pretty_date(d): @@ -37,9 +40,64 @@ def do_hide_none(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): jinja_env.filters['pretty_date'] = format_pretty_date jinja_env.filters['pretty_date_time'] = format_pretty_date_time jinja_env.filters['undertitle'] = format_undertitle 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 diff --git a/pillar/web/nodes/custom/comments.py b/pillar/web/nodes/custom/comments.py index 731f5bf9..fcbfb3ae 100644 --- a/pillar/web/nodes/custom/comments.py +++ b/pillar/web/nodes/custom/comments.py @@ -1,4 +1,7 @@ import logging +import warnings + +import flask from flask import current_app from flask import request from flask import jsonify @@ -7,6 +10,8 @@ from flask_login import login_required, current_user from pillarsdk import Node from pillarsdk import Project import werkzeug.exceptions as wz_exceptions + +from pillar.web import subquery from pillar.web.nodes.routes import blueprint from pillar.web.utils import gravatar from pillar.web.utils import pretty_date @@ -20,10 +25,22 @@ log = logging.getLogger(__name__) def comments_create(): content = request.form['content'] 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() 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, name='Comment', user=current_user.objectid, @@ -36,20 +53,18 @@ def comments_create(): rating_negative=0)) 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 # the current comment as a reply. parent_node = Node.find(parent_id, api=api) 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) - node_asset.create(api=api) + comment = Node(comment_props) + comment.create(api=api) - return jsonify( - asset_id=node_asset._id, - content=node_asset.properties.content) + return jsonify({'node_id': comment._id}), 201 @blueprint.route('/comments/', methods=['POST']) @@ -119,6 +134,8 @@ def format_comment(comment, is_reply=False, is_team=False, replies=None): @blueprint.route("/comments/") def comments_index(): + warnings.warn('comments_index() is deprecated in favour of comments_for_node()') + parent_id = request.args.get('parent_id') # Get data only if we format it api = system_util.pillar_api() @@ -152,6 +169,50 @@ def comments_index(): return return_content +@blueprint.route('//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//rate/", methods=['POST']) @login_required def comments_rate(comment_id, operation): diff --git a/requirements.txt b/requirements.txt index 9e8ab0cf..4af2b6d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ algoliasearch==1.8.0 bcrypt==2.0.0 blinker==1.4 bugsnag==2.3.1 +bleach==1.4.3 Cerberus==0.9.2 Eve==0.6.3 Events==0.2.1 @@ -19,6 +20,7 @@ google-apitools==0.4.11 httplib2==0.9.2 idna==2.0 MarkupSafe==0.23 +markdown==2.6.7 ndg-httpsclient==0.4.0 Pillow==2.8.1 pycparser==2.14 @@ -48,6 +50,7 @@ cookies==2.2.1 cryptography==1.3.1 enum34==1.1.3 funcsigs==1.0.1 +html5lib==0.9999999 googleapis-common-protos==1.1.0 ipaddress==1.0.16 itsdangerous==0.24 diff --git a/src/scripts/tutti/2_comments.js b/src/scripts/tutti/2_comments.js index 5bfd057e..b73bb3bf 100644 --- a/src/scripts/tutti/2_comments.js +++ b/src/scripts/tutti/2_comments.js @@ -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(' Reload comments'); + }); +} + + +/** + * 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(' 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; +} diff --git a/src/scripts/tutti/6_jquery_extensions.js b/src/scripts/tutti/6_jquery_extensions.js new file mode 100644 index 00000000..a5c37566 --- /dev/null +++ b/src/scripts/tutti/6_jquery_extensions.js @@ -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)); diff --git a/src/styles/_utils.sass b/src/styles/_utils.sass index 1ab884e7..360bd93b 100644 --- a/src/styles/_utils.sass +++ b/src/styles/_utils.sass @@ -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 diff --git a/src/templates/nodes/custom/_scripts.jade b/src/templates/nodes/custom/_scripts.jade index ea015b35..f1d7799d 100644 --- a/src/templates/nodes/custom/_scripts.jade +++ b/src/templates/nodes/custom/_scripts.jade @@ -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(' Reload comments'); - }); - } - - 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}}); } - diff --git a/src/templates/nodes/custom/comment/_macros.jade b/src/templates/nodes/custom/comment/_macros.jade new file mode 100644 index 00000000..cf1bd057 --- /dev/null +++ b/src/templates/nodes/custom/comment/_macros.jade @@ -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 -%} diff --git a/src/templates/nodes/custom/comment/list_embed.jade b/src/templates/nodes/custom/comment/list_embed.jade new file mode 100644 index 00000000..cb5ba551 --- /dev/null +++ b/src/templates/nodes/custom/comment/list_embed.jade @@ -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! Subscribe to Blender Cloud now. + + | {% 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(' 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($('