From fbcce7a6d8318d99a2bf779c5d93c9491282fb3a Mon Sep 17 00:00:00 2001 From: Tobias Johansson Date: Wed, 12 Dec 2018 11:45:47 +0100 Subject: [PATCH] Vue Comments: Comments ported to Vue + DnD fileupload * Drag and drop files to comment editor to add a file attachment * Using Vue to render comments Since comments now has attachments we need to update the schemas ./manage.py maintenance replace_pillar_node_type_schemas --- gulpfile.js | 4 +- package.json | 3 +- pillar/api/node_types/comment.py | 5 +- pillar/api/nodes/__init__.py | 39 +- pillar/api/nodes/comments.py | 290 +++++++ pillar/api/nodes/custom/comment.py | 23 +- pillar/web/jinja.py | 16 + pillar/web/nodes/custom/comments.py | 241 ------ pillar/web/nodes/routes.py | 10 +- src/scripts/js/es6/common/api/comments.js | 46 + src/scripts/js/es6/common/api/files.js | 54 ++ src/scripts/js/es6/common/api/markdown.js | 17 + .../es6/common/templates/nodes/NodesBase.js | 3 +- src/scripts/js/es6/common/templates/utils.js | 100 +-- .../__tests__/utils.test.js | 4 +- .../js/es6/common/utils/currentuser.js | 34 + src/scripts/js/es6/common/utils/init.js | 36 +- src/scripts/js/es6/common/utils/prettydate.js | 97 +++ .../comments/AttachmentEditor.js | 120 +++ .../common/vuecomponents/comments/Comment.js | 168 ++++ .../vuecomponents/comments/CommentEditor.js | 330 ++++++++ .../vuecomponents/comments/CommentTree.js | 157 ++++ .../vuecomponents/comments/CommentsLocked.js | 53 ++ .../common/vuecomponents/comments/EventBus.js | 7 + .../common/vuecomponents/comments/Rating.js | 52 ++ .../vuecomponents/comments/UploadProgress.js | 23 + .../js/es6/common/vuecomponents/init.js | 1 + .../common/vuecomponents/mixins/Droptarget.js | 86 ++ .../common/vuecomponents/mixins/Linkable.js | 24 + .../vuecomponents/mixins/UnitOfWorkTracker.js | 59 ++ .../es6/common/vuecomponents/user/Avatar.js | 12 + .../vuecomponents/utils/GenericPlaceHolder.js | 13 + .../vuecomponents/utils/MarkdownPreview.js | 56 ++ .../vuecomponents/utils/PrettyCreated.js | 33 + src/scripts/tutti/2_comments.js | 323 ------- src/styles/_comments.sass | 793 ++++++++---------- src/styles/_utils.sass | 3 +- src/templates/layout.pug | 2 + src/templates/nodes/custom/_scripts.pug | 3 +- .../nodes/custom/asset/view_theatre_embed.pug | 4 +- .../nodes/custom/comment/_macros.pug | 51 -- .../nodes/custom/comment/list_embed.pug | 1 - .../nodes/custom/comment/list_embed_base.pug | 258 ------ src/templates/nodes/view_base.pug | 6 +- tests/{test_web => test_api}/test_comments.py | 65 +- 45 files changed, 2248 insertions(+), 1477 deletions(-) create mode 100644 pillar/api/nodes/comments.py delete mode 100644 pillar/web/nodes/custom/comments.py create mode 100644 src/scripts/js/es6/common/api/comments.js create mode 100644 src/scripts/js/es6/common/api/files.js create mode 100644 src/scripts/js/es6/common/api/markdown.js rename src/scripts/js/es6/common/{templates => utils}/__tests__/utils.test.js (97%) create mode 100644 src/scripts/js/es6/common/utils/currentuser.js create mode 100644 src/scripts/js/es6/common/utils/prettydate.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/AttachmentEditor.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/Comment.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/CommentEditor.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/CommentsLocked.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/EventBus.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/Rating.js create mode 100644 src/scripts/js/es6/common/vuecomponents/comments/UploadProgress.js create mode 100644 src/scripts/js/es6/common/vuecomponents/init.js create mode 100644 src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js create mode 100644 src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js create mode 100644 src/scripts/js/es6/common/vuecomponents/mixins/UnitOfWorkTracker.js create mode 100644 src/scripts/js/es6/common/vuecomponents/user/Avatar.js create mode 100644 src/scripts/js/es6/common/vuecomponents/utils/GenericPlaceHolder.js create mode 100644 src/scripts/js/es6/common/vuecomponents/utils/MarkdownPreview.js create mode 100644 src/scripts/js/es6/common/vuecomponents/utils/PrettyCreated.js delete mode 100644 src/scripts/tutti/2_comments.js delete mode 100644 src/templates/nodes/custom/comment/_macros.pug delete mode 100644 src/templates/nodes/custom/comment/list_embed.pug delete mode 100644 src/templates/nodes/custom/comment/list_embed_base.pug rename tests/{test_web => test_api}/test_comments.py (53%) diff --git a/gulpfile.js b/gulpfile.js index 10e892df..26cad555 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -40,7 +40,8 @@ let destination = { let source = { bootstrap: 'node_modules/bootstrap/', jquery: 'node_modules/jquery/', - popper: 'node_modules/popper.js/' + popper: 'node_modules/popper.js/', + vue: 'node_modules/vue/', } /* Stylesheets */ @@ -135,6 +136,7 @@ gulp.task('scripts_concat_tutti', function(done) { let toUglify = [ source.jquery + 'dist/jquery.min.js', + source.vue + 'dist/vue.min.js', source.popper + 'dist/umd/popper.min.js', source.bootstrap + 'js/dist/index.js', source.bootstrap + 'js/dist/util.js', diff --git a/package.json b/package.json index 37b7b811..fe809cb3 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "glob": "7.1.3", "jquery": "3.3.1", "popper.js": "1.14.4", - "video.js": "7.2.2" + "video.js": "7.2.2", + "vue": "2.5.17" }, "scripts": { "test": "jest" diff --git a/pillar/api/node_types/comment.py b/pillar/api/node_types/comment.py index 43fe92c7..e12b3bab 100644 --- a/pillar/api/node_types/comment.py +++ b/pillar/api/node_types/comment.py @@ -1,3 +1,5 @@ +from pillar.api.node_types import attachments_embedded_schema + node_type_comment = { 'name': 'comment', 'description': 'Comments for asset nodes, pages, etc.', @@ -51,7 +53,8 @@ node_type_comment = { } }, 'confidence': {'type': 'float'}, - 'is_reply': {'type': 'boolean'} + 'is_reply': {'type': 'boolean'}, + 'attachments': attachments_embedded_schema, }, 'form_schema': {}, 'parent': ['asset', 'comment'], diff --git a/pillar/api/nodes/__init__.py b/pillar/api/nodes/__init__.py index 240d1f7f..c3f76389 100644 --- a/pillar/api/nodes/__init__.py +++ b/pillar/api/nodes/__init__.py @@ -6,14 +6,14 @@ import pymongo.errors import werkzeug.exceptions as wz_exceptions from flask import current_app, Blueprint, request -from pillar.api.nodes import eve_hooks +from pillar.api.nodes import eve_hooks, comments from pillar.api.utils import str2id, jsonify from pillar.api.utils.authorization import check_permissions, require_login from pillar.web.utils import pretty_date log = logging.getLogger(__name__) blueprint = Blueprint('nodes_api', __name__) -ROLES_FOR_SHARING = {'subscriber', 'demo'} +ROLES_FOR_SHARING = ROLES_FOR_COMMENTING ={'subscriber', 'demo'} @blueprint.route('//share', methods=['GET', 'POST']) @@ -51,6 +51,41 @@ def share_node(node_id): return jsonify(eve_hooks.short_link_info(short_code), status=status) +@blueprint.route('//comments', methods=['GET']) +def get_node_comments(node_path: str): + node_id = str2id(node_path) + return comments.get_node_comments(node_id) + + +@blueprint.route('//comments', methods=['POST']) +@require_login(require_roles=ROLES_FOR_COMMENTING) +def post_node_comment(node_path: str): + node_id = str2id(node_path) + msg = request.json['msg'] + attachments = request.json.get('attachments', {}) + return comments.post_node_comment(node_id, msg, attachments) + + +@blueprint.route('//comments/', methods=['PATCH']) +@require_login(require_roles=ROLES_FOR_COMMENTING) +def patch_node_comment(node_path: str, comment_path: str): + node_id = str2id(node_path) + comment_id = str2id(comment_path) + msg = request.json['msg'] + attachments = request.json.get('attachments', {}) + return comments.patch_node_comment(node_id, comment_id, msg, attachments) + + +@blueprint.route('//comments//vote', methods=['POST']) +@require_login(require_roles=ROLES_FOR_COMMENTING) +def post_node_comment_vote(node_path: str, comment_path: str): + node_id = str2id(node_path) + comment_id = str2id(comment_path) + vote_str = request.json['vote'] + vote = int(vote_str) + return comments.post_node_comment_vote(node_id, comment_id, vote) + + @blueprint.route('/tagged/') @blueprint.route('/tagged/') def tagged(tag=''): diff --git a/pillar/api/nodes/comments.py b/pillar/api/nodes/comments.py new file mode 100644 index 00000000..7e324029 --- /dev/null +++ b/pillar/api/nodes/comments.py @@ -0,0 +1,290 @@ +import logging +from datetime import datetime + +import pymongo +import typing + +import bson +import attr +import werkzeug.exceptions as wz_exceptions + +import pillar +from pillar import current_app, shortcodes +from pillar.api.nodes.custom.comment import patch_comment +from pillar.api.utils import jsonify, gravatar +from pillar.auth import current_user + + +log = logging.getLogger(__name__) + + +@attr.s(auto_attribs=True) +class UserDO: + id: str + full_name: str + gravatar: str + badges_html: str + + +@attr.s(auto_attribs=True) +class CommentPropertiesDO: + attachments: typing.Dict + rating_positive: int = 0 + rating_negative: int = 0 + + +@attr.s(auto_attribs=True) +class CommentDO: + id: bson.ObjectId + parent: bson.ObjectId + project: bson.ObjectId + user: UserDO + msg_html: str + msg_markdown: str + properties: CommentPropertiesDO + created: datetime + updated: datetime + etag: str + replies: typing.List['CommentDO'] = [] + current_user_rating: typing.Optional[bool] = None + + +@attr.s(auto_attribs=True) +class CommentTreeDO: + node_id: bson.ObjectId + project: bson.ObjectId + nbr_of_comments: int = 0 + comments: typing.List[CommentDO] = [] + + +def _get_markdowned_html(document: dict, field_name: str) -> str: + cache_field_name = pillar.markdown.cache_field_name(field_name) + html = document.get(cache_field_name) + if html is None: + markdown_src = document.get(field_name) or '' + html = pillar.markdown.markdown(markdown_src) + return html + + +def jsonify_data_object(data_object: attr): + return jsonify( + attr.asdict(data_object, + recurse=True) + ) + + +class CommentTreeBuilder: + def __init__(self, node_id: bson.ObjectId): + self.node_id = node_id + self.nbr_of_Comments: int = 0 + + def build(self) -> CommentTreeDO: + enriched_comments = self.child_comments(self.node_id) + project_id = self.get_project_id() + return CommentTreeDO( + node_id=self.node_id, + project=project_id, + nbr_of_comments=self.nbr_of_Comments, + comments=enriched_comments + ) + + def child_comments(self, node_id: bson.ObjectId) -> typing.List[CommentDO]: + raw_comments = self.mongodb_comments(node_id) + return [self.enrich(comment) for comment in raw_comments] + + def enrich(self, mongo_comment: dict) -> CommentDO: + self.nbr_of_Comments += 1 + comment = to_comment_data_object(mongo_comment) + comment.replies = self.child_comments(mongo_comment['_id']) + return comment + + def get_project_id(self): + nodes_coll = current_app.db('nodes') + result = nodes_coll.find_one({'_id': self.node_id}) + return result['project'] + + @classmethod + def mongodb_comments(cls, node_id: bson.ObjectId) -> typing.Iterator: + nodes_coll = current_app.db('nodes') + return nodes_coll.aggregate([ + {'$match': {'node_type': 'comment', + '_deleted': {'$ne': True}, + 'properties.status': 'published', + 'parent': node_id}}, + {'$lookup': {"from": "users", + "localField": "user", + "foreignField": "_id", + "as": "user"}}, + {'$unwind': {'path': "$user"}}, + {'$sort': {'properties.rating_positive': pymongo.DESCENDING, + '_created': pymongo.DESCENDING}}, + ]) + + +def get_node_comments(node_id: bson.ObjectId): + comments_tree = CommentTreeBuilder(node_id).build() + return jsonify_data_object(comments_tree) + + +def post_node_comment(parent_id: bson.ObjectId, markdown_msg: str, attachments: dict): + parent_node = find_node_or_raise(parent_id, + 'User %s tried to update comment with bad parent_id %s', + current_user.objectid, + parent_id) + + is_reply = parent_node['node_type'] == 'comment' + comment = dict( + parent=parent_id, + project=parent_node['project'], + name='Comment', + user=current_user.objectid, + node_type='comment', + properties=dict( + content=markdown_msg, + status='published', + is_reply=is_reply, + confidence=0, + rating_positive=0, + rating_negative=0, + attachments=attachments, + ) + ) + r, _, _, status = current_app.post_internal('nodes', comment) + + if status != 201: + log.warning('Unable to post comment on %s as %s: %s', + parent_id, current_user.objectid, r) + raise wz_exceptions.InternalServerError('Unable to create comment') + + comment_do = get_comment(parent_id, r['_id']) + + return jsonify_data_object(comment_do), 201 + + +def find_node_or_raise(node_id, *args): + nodes_coll = current_app.db('nodes') + node_to_comment = nodes_coll.find_one({ + '_id': node_id, + '_deleted': {'$ne': True}, + }) + if not node_to_comment: + log.warning(args) + raise wz_exceptions.UnprocessableEntity() + return node_to_comment + + +def patch_node_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId, markdown_msg: str, attachments: dict): + _, _ = find_parent_and_comment_or_raise(parent_id, comment_id) + + patch = dict( + op='edit', + content=markdown_msg, + attachments=attachments + ) + + json_result = patch_comment(comment_id, patch) + if json_result.json['result'] != 200: + raise wz_exceptions.InternalServerError('Failed to update comment') + + comment_do = get_comment(parent_id, comment_id) + + return jsonify_data_object(comment_do), 200 + + +def find_parent_and_comment_or_raise(parent_id, comment_id): + parent = find_node_or_raise(parent_id, + 'User %s tried to update comment with bad parent_id %s', + current_user.objectid, + parent_id) + comment = find_node_or_raise(comment_id, + 'User %s tried to update comment with bad id %s', + current_user.objectid, + comment_id) + validate_comment_parent_relation(comment, parent) + return parent, comment + + +def validate_comment_parent_relation(comment, parent): + if comment['parent'] != parent['_id']: + log.warning('User %s tried to update comment with bad parent/comment pair. parent_id: %s comment_id: %s', + current_user.objectid, + parent['_id'], + comment['_id']) + raise wz_exceptions.BadRequest() + + +def get_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId) -> CommentDO: + nodes_coll = current_app.db('nodes') + mongo_comment = list(nodes_coll.aggregate([ + {'$match': {'node_type': 'comment', + '_deleted': {'$ne': True}, + 'properties.status': 'published', + 'parent': parent_id, + '_id': comment_id}}, + {'$lookup': {"from": "users", + "localField": "user", + "foreignField": "_id", + "as": "user"}}, + {'$unwind': {'path': "$user"}}, + ]))[0] + + return to_comment_data_object(mongo_comment) + + +def to_comment_data_object(mongo_comment: dict) -> CommentDO: + def current_user_rating(): + if current_user.is_authenticated: + for rating in mongo_comment['properties'].get('ratings', ()): + if str(rating['user']) != current_user.objectid: + continue + return rating['is_positive'] + return None + + user_dict = mongo_comment['user'] + user = UserDO( + id=str(mongo_comment['user']['_id']), + full_name=user_dict['full_name'], + gravatar=gravatar(user_dict['email']), + badges_html=user_dict.get('badges', {}).get('html', '') + ) + html = _get_markdowned_html(mongo_comment['properties'], 'content') + html = shortcodes.render_commented(html, context=mongo_comment['properties']) + return CommentDO( + id=mongo_comment['_id'], + parent=mongo_comment['parent'], + project=mongo_comment['project'], + user=user, + msg_html=html, + msg_markdown=mongo_comment['properties']['content'], + current_user_rating=current_user_rating(), + created=mongo_comment['_created'], + updated=mongo_comment['_updated'], + etag=mongo_comment['_etag'], + properties=CommentPropertiesDO( + attachments=mongo_comment['properties'].get('attachments', {}), + rating_positive=mongo_comment['properties']['rating_positive'], + rating_negative=mongo_comment['properties']['rating_negative'] + ) + ) + + +def post_node_comment_vote(parent_id: bson.ObjectId, comment_id: bson.ObjectId, vote: int): + normalized_vote = min(max(vote, -1), 1) + _, _ = find_parent_and_comment_or_raise(parent_id, comment_id) + + actions = { + 1: 'upvote', + 0: 'revoke', + -1: 'downvote', + } + + patch = dict( + op=actions[normalized_vote] + ) + + json_result = patch_comment(comment_id, patch) + if json_result.json['_status'] != 'OK': + raise wz_exceptions.InternalServerError('Failed to vote on comment') + + comment_do = get_comment(parent_id, comment_id) + return jsonify_data_object(comment_do), 200 diff --git a/pillar/api/nodes/custom/comment.py b/pillar/api/nodes/custom/comment.py index 44bf9fe5..c6004538 100644 --- a/pillar/api/nodes/custom/comment.py +++ b/pillar/api/nodes/custom/comment.py @@ -5,7 +5,7 @@ import logging from flask import current_app import werkzeug.exceptions as wz_exceptions -from pillar.api.utils import authorization, authentication, jsonify +from pillar.api.utils import authorization, authentication, jsonify, remove_private_keys from . import register_patch_handler @@ -135,10 +135,7 @@ def edit_comment(user_id, node_id, patch): # we can pass this stuff to Eve's patch_internal; that way the validation & # authorisation system has enough info to work. nodes_coll = current_app.data.driver.db['nodes'] - projection = {'user': 1, - 'project': 1, - 'node_type': 1} - node = nodes_coll.find_one(node_id, projection=projection) + node = nodes_coll.find_one(node_id) if node is None: log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id)) raise wz_exceptions.NotFound('Node %s not found' % node_id) @@ -146,14 +143,14 @@ def edit_comment(user_id, node_id, patch): if node['user'] != user_id and not authorization.user_has_role('admin'): raise wz_exceptions.Forbidden('You can only edit your own comments.') - # Use Eve to PATCH this node, as that also updates the etag. - r, _, _, status = current_app.patch_internal('nodes', - {'properties.content': patch['content'], - 'project': node['project'], - 'user': node['user'], - 'node_type': node['node_type']}, - concurrency_check=False, - _id=node_id) + node = remove_private_keys(node) + node['properties']['content'] = patch['content'] + node['properties']['attachments'] = patch.get('attachments', {}) + # Use Eve to PUT this node, as that also updates the etag and we want to replace attachments. + r, _, _, status = current_app.put_internal('nodes', + node, + concurrency_check=False, + _id=node_id) if status != 200: log.error('Error %i editing comment %s for user %s: %s', status, node_id, user_id, r) diff --git a/pillar/web/jinja.py b/pillar/web/jinja.py index 66fe6aa7..4677d737 100644 --- a/pillar/web/jinja.py +++ b/pillar/web/jinja.py @@ -14,6 +14,7 @@ import werkzeug.exceptions as wz_exceptions import pillarsdk import pillar.api.utils +from pillar import auth from pillar.api.utils import pretty_duration from pillar.web.utils import pretty_date from pillar.web.nodes.routes import url_for_node @@ -206,9 +207,24 @@ def do_yesno(value, arg=None): return no +def user_to_dict(user: auth.UserClass) -> dict: + return dict( + user_id=str(user.user_id), + username=user.username, + full_name=user.full_name, + gravatar=user.gravatar, + email=user.email, + capabilities=list(user.capabilities), + badges_html=user.badges_html, + is_authenticated=user.is_authenticated + ) + + def do_json(some_object) -> str: if isinstance(some_object, pillarsdk.Resource): some_object = some_object.to_dict() + if isinstance(some_object, auth.UserClass): + some_object = user_to_dict(some_object) return json.dumps(some_object) diff --git a/pillar/web/nodes/custom/comments.py b/pillar/web/nodes/custom/comments.py deleted file mode 100644 index 3c2b0644..00000000 --- a/pillar/web/nodes/custom/comments.py +++ /dev/null @@ -1,241 +0,0 @@ -import logging - -from flask import current_app -from flask import request -from flask import jsonify -from flask import render_template -from flask_login import login_required, current_user -from pillarsdk import Node -from pillarsdk import Project -import werkzeug.exceptions as wz_exceptions - -from pillar.api.utils import utcnow -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 -from pillar.web.utils import system_util - -log = logging.getLogger(__name__) - - -@blueprint.route('/comments/create', methods=['POST']) -@login_required -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() - - log.info('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, - node_type='comment', - properties=dict( - content=content, - status='published', - confidence=0, - rating_positive=0, - rating_negative=0)) - - if 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': - comment_props['properties']['is_reply'] = True - - comment = Node(comment_props) - comment.create(api=api) - - return jsonify({'node_id': comment._id}), 201 - - -@blueprint.route('/comments/', methods=['POST']) -@login_required -def comment_edit(comment_id): - """Allows a user to edit their comment.""" - from pillar.web import jinja - - api = system_util.pillar_api() - - comment = Node({'_id': comment_id}) - result = comment.patch({'op': 'edit', 'content': request.form['content']}, api=api) - assert result['_status'] == 'OK' - - return jsonify({ - 'status': 'success', - 'data': { - 'content': result.properties.content or '', - 'content_html': jinja.do_markdowned(result.properties, 'content'), - }}) - - -def format_comment(comment, is_reply=False, is_team=False, replies=None): - """Format a comment node into a simpler dictionary. - - :param comment: the comment object - :param is_reply: True if the comment is a reply to another comment - :param is_team: True if the author belongs to the group that owns the node - :param replies: list of replies (formatted with this function) - """ - try: - is_own = (current_user.objectid == comment.user._id) \ - if current_user.is_authenticated else False - except AttributeError: - current_app.bugsnag.notify(Exception( - 'Missing user for embedded user ObjectId'), - meta_data={'nodes_info': {'node_id': comment['_id']}}) - return - is_rated = False - is_rated_positive = None - if comment.properties.ratings: - for rating in comment.properties.ratings: - if current_user.is_authenticated and rating.user == current_user.objectid: - is_rated = True - is_rated_positive = rating.is_positive - break - - return dict(_id=comment._id, - gravatar=gravatar(comment.user.email, size=32), - time_published=pretty_date(comment._created or utcnow(), detail=True), - rating=comment.properties.rating_positive - comment.properties.rating_negative, - author=comment.user.full_name, - author_username=comment.user.username, - content=comment.properties.content, - is_reply=is_reply, - is_own=is_own, - is_rated=is_rated, - is_rated_positive=is_rated_positive, - is_team=is_team, - replies=replies) - - -@blueprint.route('//comments') -def comments_for_node(node_id): - """Shows the comments attached to the given node. - - The URL can be overridden in order to define can_post_comments in a different way - """ - - 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) - can_comment_override = request.args.get('can_comment', 'True') == 'True' - can_post_comments = can_post_comments and can_comment_override - - return render_comments_for_node(node_id, can_post_comments=can_post_comments) - - -def render_comments_for_node(node_id: str, *, can_post_comments: bool): - """Render the list of comments for a node. - - Comments are first sorted by rating_positive and then by creation date. - """ - api = system_util.pillar_api() - - # Query for all children, i.e. comments on the node. - comments = Node.all({ - 'where': {'node_type': 'comment', 'parent': node_id}, - 'sort': [('properties.rating_positive', -1), ('_created', -1)], - }, 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 - some_comment[ - '_rating'] = some_comment.properties.rating_positive - some_comment.properties.rating_negative - - if current_user.is_authenticated: - for rating in some_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']}, - 'sort': [('properties.rating_positive', -1), ('_created', -1)], - }, api=api) - - enrich(comment) - for reply in comment['_replies']['_items']: - enrich(reply) - nr_of_comments = sum(1 + comment['_replies']['_meta']['total'] - for comment in comments['_items']) - return render_template('nodes/custom/comment/list_embed.html', - node_id=node_id, - comments=comments, - nr_of_comments=nr_of_comments, - show_comments=True, - can_post_comments=can_post_comments) - - -@blueprint.route('//commentform') -def commentform_for_node(node_id): - """Shows only the comment for for comments attached to the given node. - - i.e. does not show the comments themselves, just the form to post a new comment. - """ - - 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) - - return render_template('nodes/custom/comment/list_embed.html', - node_id=node_id, - show_comments=False, - can_post_comments=can_post_comments) - - -@blueprint.route("/comments//rate/", methods=['POST']) -@login_required -def comments_rate(comment_id, operation): - """Comment rating function - - :param comment_id: the comment id - :type comment_id: str - :param rating: the rating (is cast from 0 to False and from 1 to True) - :type rating: int - - """ - - if operation not in {'revoke', 'upvote', 'downvote'}: - raise wz_exceptions.BadRequest('Invalid operation') - - api = system_util.pillar_api() - - # PATCH the node and return the result. - comment = Node({'_id': comment_id}) - result = comment.patch({'op': operation}, api=api) - assert result['_status'] == 'OK' - - return jsonify({ - 'status': 'success', - 'data': { - 'op': operation, - 'rating_positive': result.properties.rating_positive, - 'rating_negative': result.properties.rating_negative, - }}) diff --git a/pillar/web/nodes/routes.py b/pillar/web/nodes/routes.py index fd75313f..78b015b5 100644 --- a/pillar/web/nodes/routes.py +++ b/pillar/web/nodes/routes.py @@ -4,6 +4,7 @@ import logging from datetime import datetime import pillarsdk +from pillar import shortcodes from pillarsdk import Node from pillarsdk import Project from pillarsdk.exceptions import ResourceNotFound @@ -487,11 +488,14 @@ def preview_markdown(): current_app.csrf.protect() try: - content = request.form['content'] + content = request.json['content'] except KeyError: return jsonify({'_status': 'ERR', 'message': 'The field "content" was not specified.'}), 400 - return jsonify(content=markdown(content)) + html = markdown(content) + attachmentsdict = request.json.get('attachments', {}) + html = shortcodes.render_commented(html, context={'attachments': attachmentsdict}) + return jsonify(content=html) def ensure_lists_exist_as_empty(node_doc, node_type): @@ -605,4 +609,4 @@ def url_for_node(node_id=None, node=None): # Import of custom modules (using the same nodes decorator) -from .custom import comments, groups, storage, posts +from .custom import groups, storage, posts diff --git a/src/scripts/js/es6/common/api/comments.js b/src/scripts/js/es6/common/api/comments.js new file mode 100644 index 00000000..f59b1c03 --- /dev/null +++ b/src/scripts/js/es6/common/api/comments.js @@ -0,0 +1,46 @@ +function thenGetComments(parentId) { + return $.getJSON(`/api/nodes/${parentId}/comments`); +} + +function thenCreateComment(parentId, msg, attachments) { + let data = JSON.stringify({ + msg: msg, + attachments: attachments + }); + return $.ajax({ + url: `/api/nodes/${parentId}/comments`, + type: 'POST', + data: data, + dataType: 'json', + contentType: 'application/json; charset=UTF-8' + }); +} + +function thenUpdateComment(parentId, commentId, msg, attachments) { + let data = JSON.stringify({ + msg: msg, + attachments: attachments + }); + return $.ajax({ + url: `/api/nodes/${parentId}/comments/${commentId}`, + type: 'PATCH', + data: data, + dataType: 'json', + contentType: 'application/json; charset=UTF-8' + }); +} + +function thenVoteComment(parentId, commentId, vote) { + let data = JSON.stringify({ + vote: vote + }); + return $.ajax({ + url: `/api/nodes/${parentId}/comments/${commentId}/vote`, + type: 'POST', + data: data, + dataType: 'json', + contentType: 'application/json; charset=UTF-8' + }); +} + +export { thenGetComments, thenCreateComment, thenUpdateComment, thenVoteComment } \ No newline at end of file diff --git a/src/scripts/js/es6/common/api/files.js b/src/scripts/js/es6/common/api/files.js new file mode 100644 index 00000000..be8bd893 --- /dev/null +++ b/src/scripts/js/es6/common/api/files.js @@ -0,0 +1,54 @@ +function thenUploadFile(projectId, file, progressCB=(total, loaded)=>{}) { + let formData = createFormData(file) + return $.ajax({ + url: `/api/storage/stream/${projectId}`, + type: 'POST', + data: formData, + + cache: false, + contentType: false, + processData: false, + + xhr: () => { + let myxhr = $.ajaxSettings.xhr(); + if (myxhr.upload) { + // For handling the progress of the upload + myxhr.upload.addEventListener('progress', function(e) { + if (e.lengthComputable) { + progressCB(e.total, e.loaded); + } + }, false); + } + return myxhr; + } + }); +} + +function createFormData(file) { + let formData = new FormData(); + formData.append('file', file); + + return formData; +} + +function thenGetFileDocument(fileId) { + return $.get(`/api/files/${fileId}`); +} + +function getFileVariation(fileDoc, size = 'm') { + var show_variation = null; + if (typeof fileDoc.variations != 'undefined') { + for (var variation of fileDoc.variations) { + if (variation.size != size) continue; + show_variation = variation; + break; + } + } + + if (show_variation == null) { + throw 'Image not found: ' + fileDoc._id + ' size: ' + size; + } + return show_variation; +} + +export { thenUploadFile, thenGetFileDocument, getFileVariation } \ No newline at end of file diff --git a/src/scripts/js/es6/common/api/markdown.js b/src/scripts/js/es6/common/api/markdown.js new file mode 100644 index 00000000..31e41c68 --- /dev/null +++ b/src/scripts/js/es6/common/api/markdown.js @@ -0,0 +1,17 @@ +function thenMarkdownToHtml(markdown, attachments={}) { + let data = JSON.stringify({ + content: markdown, + attachments: attachments + }); + return $.ajax({ + url: "/nodes/preview-markdown", + type: 'POST', + headers: {"X-CSRFToken": csrf_token}, + headers: {}, + data: data, + dataType: 'json', + contentType: 'application/json; charset=UTF-8' + }) +} + +export { thenMarkdownToHtml } \ No newline at end of file diff --git a/src/scripts/js/es6/common/templates/nodes/NodesBase.js b/src/scripts/js/es6/common/templates/nodes/NodesBase.js index 4c0bc125..76ad9c17 100644 --- a/src/scripts/js/es6/common/templates/nodes/NodesBase.js +++ b/src/scripts/js/es6/common/templates/nodes/NodesBase.js @@ -1,4 +1,5 @@ -import { thenLoadImage, prettyDate } from '../utils'; +import { prettyDate } from '../../utils/prettydate'; +import { thenLoadImage } from '../utils'; import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface' export class NodesBase extends ComponentCreatorInterface { diff --git a/src/scripts/js/es6/common/templates/utils.js b/src/scripts/js/es6/common/templates/utils.js index 8f4119f9..36e96d8c 100644 --- a/src/scripts/js/es6/common/templates/utils.js +++ b/src/scripts/js/es6/common/templates/utils.js @@ -21,102 +21,4 @@ function thenLoadVideoProgress(nodeId) { return $.get('/api/users/video/' + nodeId + '/progress') } -function prettyDate(time, detail=false) { - /** - * time is anything Date can parse, and we return a - pretty string like 'an hour ago', 'Yesterday', '3 months ago', - 'just now', etc - */ - let theDate = new Date(time); - if (!time || isNaN(theDate)) { - return - } - let pretty = ''; - let now = new Date(Date.now()); // Easier to mock Date.now() in tests - let second_diff = Math.round((now - theDate) / 1000); - - let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24) - - if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) { - // "Jul 16, 2018" - pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'}); - } - else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) { - // "Jul 16" - pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'}); - } - else if (day_diff < -7){ - let week_count = Math.round(-day_diff / 7); - if (week_count == 1) - pretty = "in 1 week"; - else - pretty = "in " + week_count +" weeks"; - } - else if (day_diff < -1) - // "next Tuesday" - pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'}); - else if (day_diff === 0) { - if (second_diff < 0) { - let seconds = Math.abs(second_diff); - if (seconds < 10) - return 'just now'; - if (seconds < 60) - return 'in ' + seconds +'s'; - if (seconds < 120) - return 'in a minute'; - if (seconds < 3600) - return 'in ' + Math.round(seconds / 60) + 'm'; - if (seconds < 7200) - return 'in an hour'; - if (seconds < 86400) - return 'in ' + Math.round(seconds / 3600) + 'h'; - } else { - let seconds = second_diff; - if (seconds < 10) - return "just now"; - if (seconds < 60) - return seconds + "s ago"; - if (seconds < 120) - return "a minute ago"; - if (seconds < 3600) - return Math.round(seconds / 60) + "m ago"; - if (seconds < 7200) - return "an hour ago"; - if (seconds < 86400) - return Math.round(seconds / 3600) + "h ago"; - } - - } - else if (day_diff == 1) - pretty = "yesterday"; - - else if (day_diff <= 7) - // "last Tuesday" - pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'}); - - else if (day_diff <= 22) { - let week_count = Math.round(day_diff / 7); - if (week_count == 1) - pretty = "1 week ago"; - else - pretty = week_count + " weeks ago"; - } - else if (theDate.getFullYear() === now.getFullYear()) - // "Jul 16" - pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'}); - - else - // "Jul 16", 2009 - pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'}); - - if (detail){ - // "Tuesday at 04:20" - let paddedHour = ('00' + theDate.getUTCHours()).substr(-2); - let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2); - return pretty + ' at ' + paddedHour + ':' + paddedMin; - } - - return pretty; -} - -export { thenLoadImage, thenLoadVideoProgress, prettyDate }; \ No newline at end of file +export { thenLoadImage, thenLoadVideoProgress }; \ No newline at end of file diff --git a/src/scripts/js/es6/common/templates/__tests__/utils.test.js b/src/scripts/js/es6/common/utils/__tests__/utils.test.js similarity index 97% rename from src/scripts/js/es6/common/templates/__tests__/utils.test.js rename to src/scripts/js/es6/common/utils/__tests__/utils.test.js index 4e7a5eba..4a54beb4 100644 --- a/src/scripts/js/es6/common/templates/__tests__/utils.test.js +++ b/src/scripts/js/es6/common/utils/__tests__/utils.test.js @@ -1,4 +1,4 @@ -import { prettyDate } from '../utils' +import { prettyDate } from '../init' describe('prettydate', () => { beforeEach(() => { @@ -28,7 +28,7 @@ describe('prettydate', () => { expect(pd({minutes: -5, detailed: true})).toBe('5m ago') expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46') expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46') - // summer time bellow + // summer time below expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46') expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46') expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46') diff --git a/src/scripts/js/es6/common/utils/currentuser.js b/src/scripts/js/es6/common/utils/currentuser.js new file mode 100644 index 00000000..e3347fe9 --- /dev/null +++ b/src/scripts/js/es6/common/utils/currentuser.js @@ -0,0 +1,34 @@ +class User{ + constructor(kwargs) { + this.user_id = kwargs['user_id'] || ''; + this.username = kwargs['username'] || ''; + this.full_name = kwargs['full_name'] || ''; + this.gravatar = kwargs['gravatar'] || ''; + this.email = kwargs['email'] || ''; + this.capabilities = kwargs['capabilities'] || []; + this.badges_html = kwargs['badges_html'] || ''; + this.is_authenticated = kwargs['is_authenticated'] || false; + } + + /** + * """Returns True iff the user has one or more of the given capabilities.""" + * @param {...String} args + */ + hasCap(...args) { + for(let cap of args) { + if (this.capabilities.indexOf(cap) != -1) return true; + } + return false; + } +} + +let currentUser; +function initCurrentUser(kwargs){ + currentUser = new User(kwargs); +} + +function getCurrentUser() { + return currentUser; +} + +export { getCurrentUser, initCurrentUser } \ No newline at end of file diff --git a/src/scripts/js/es6/common/utils/init.js b/src/scripts/js/es6/common/utils/init.js index 7d5f51c1..2f5e7b5b 100644 --- a/src/scripts/js/es6/common/utils/init.js +++ b/src/scripts/js/es6/common/utils/init.js @@ -1 +1,35 @@ -export { transformPlaceholder } from './placeholder' \ No newline at end of file +export { transformPlaceholder } from './placeholder' +export { prettyDate } from './prettydate' +export { getCurrentUser, initCurrentUser } from './currentuser' + + +export function debounced(fn, delay=1000) { + let timerId; + return function (...args) { + if (timerId) { + clearTimeout(timerId); + } + timerId = setTimeout(() => { + fn(...args); + timerId = null; + }, delay); + } + } + +/** + * Extracts error message from error of type String, Error or xhrError + * @param {*} err + * @returns {String} + */ +export function messageFromError(err){ + if (typeof err === "string") { + // type String + return err; + } else if(typeof err.message === "string") { + // type Error + return err.message; + } else { + // type xhr probably + return xhrErrorResponseMessage(err); + } +} \ No newline at end of file diff --git a/src/scripts/js/es6/common/utils/prettydate.js b/src/scripts/js/es6/common/utils/prettydate.js new file mode 100644 index 00000000..61a5b441 --- /dev/null +++ b/src/scripts/js/es6/common/utils/prettydate.js @@ -0,0 +1,97 @@ +export function prettyDate(time, detail=false) { + /** + * time is anything Date can parse, and we return a + pretty string like 'an hour ago', 'Yesterday', '3 months ago', + 'just now', etc + */ + let theDate = new Date(time); + if (!time || isNaN(theDate)) { + return + } + let pretty = ''; + let now = new Date(Date.now()); // Easier to mock Date.now() in tests + let second_diff = Math.round((now - theDate) / 1000); + + let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24) + + if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) { + // "Jul 16, 2018" + pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'}); + } + else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) { + // "Jul 16" + pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'}); + } + else if (day_diff < -7){ + let week_count = Math.round(-day_diff / 7); + if (week_count == 1) + pretty = "in 1 week"; + else + pretty = "in " + week_count +" weeks"; + } + else if (day_diff < -1) + // "next Tuesday" + pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'}); + else if (day_diff === 0) { + if (second_diff < 0) { + let seconds = Math.abs(second_diff); + if (seconds < 10) + return 'just now'; + if (seconds < 60) + return 'in ' + seconds +'s'; + if (seconds < 120) + return 'in a minute'; + if (seconds < 3600) + return 'in ' + Math.round(seconds / 60) + 'm'; + if (seconds < 7200) + return 'in an hour'; + if (seconds < 86400) + return 'in ' + Math.round(seconds / 3600) + 'h'; + } else { + let seconds = second_diff; + if (seconds < 10) + return "just now"; + if (seconds < 60) + return seconds + "s ago"; + if (seconds < 120) + return "a minute ago"; + if (seconds < 3600) + return Math.round(seconds / 60) + "m ago"; + if (seconds < 7200) + return "an hour ago"; + if (seconds < 86400) + return Math.round(seconds / 3600) + "h ago"; + } + + } + else if (day_diff == 1) + pretty = "yesterday"; + + else if (day_diff <= 7) + // "last Tuesday" + pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'}); + + else if (day_diff <= 22) { + let week_count = Math.round(day_diff / 7); + if (week_count == 1) + pretty = "1 week ago"; + else + pretty = week_count + " weeks ago"; + } + else if (theDate.getFullYear() === now.getFullYear()) + // "Jul 16" + pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'}); + + else + // "Jul 16", 2009 + pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'}); + + if (detail){ + // "Tuesday at 04:20" + let paddedHour = ('00' + theDate.getUTCHours()).substr(-2); + let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2); + return pretty + ' at ' + paddedHour + ':' + paddedMin; + } + + return pretty; +} \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/comments/AttachmentEditor.js b/src/scripts/js/es6/common/vuecomponents/comments/AttachmentEditor.js new file mode 100644 index 00000000..72e86954 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/AttachmentEditor.js @@ -0,0 +1,120 @@ +import { thenGetFileDocument, getFileVariation } from '../../api/files' +import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker' + +const VALID_NAME_REGEXP = /[a-zA-Z0-9_\-]+/g; +const NON_VALID_NAME_REGEXP = /[^a-zA-Z0-9_\-]+/g; +const TEMPLATE = ` +
+
+ + +
+ +
+
+ + Delete +
+
+
+`; + +Vue.component('comment-attachment-editor', { + template: TEMPLATE, + mixins: [UnitOfWorkTracker], + props: { + slug: String, + allSlugs: Array, + oid: String + }, + data() { + return { + newSlug: this.slug, + thumbnail: '', + thumbnailBackup: 'pi-spin spin', + } + }, + computed: { + isValidAttachmentName() { + let regexpMatch = this.slug.match(VALID_NAME_REGEXP); + return !!regexpMatch && regexpMatch.length === 1 && regexpMatch[0] === this.slug; + }, + isUnique() { + let countOccurrences = 0; + for (let s of this.allSlugs) { + // Don't worry about unicode. isValidAttachmentName denies those anyway + if (s.toUpperCase() === this.slug.toUpperCase()) { + countOccurrences++; + } + } + return countOccurrences === 1; + }, + isSlugOk() { + return this.isValidAttachmentName && this.isUnique; + } + }, + watch: { + newSlug(newValue, oldValue) { + this.$emit('rename', newValue, this.oid); + }, + isSlugOk(newValue, oldValue) { + this.$emit('validation', this.oid, newValue); + } + }, + created() { + this.newSlug = this.makeSafeAttachmentString(this.slug); + this.$emit('validation', this.oid, this.isSlugOk); + + this.unitOfWork( + thenGetFileDocument(this.oid) + .then((fileDoc) => { + let content_type = fileDoc.content_type + if (content_type.startsWith('image')) { + try { + let imgFile = getFileVariation(fileDoc, 's'); + this.thumbnail = imgFile.link; + } catch (error) { + this.thumbnailBackup = 'pi-image'; + } + } else if(content_type.startsWith('video')) { + this.thumbnailBackup = 'pi-video'; + } else { + this.thumbnailBackup = 'pi-file'; + } + }) + ); + }, + methods: { + /** + * Replaces all spaces with underscore and removes all o + * @param {String} unsafe + * @returns {String} + */ + makeSafeAttachmentString(unsafe) { + let candidate = (unsafe); + let matchSpace = / /g; + candidate = candidate + .replace(matchSpace, '_') + .replace(NON_VALID_NAME_REGEXP, '') + + return candidate || `${this.oid}` + } + } +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/comments/Comment.js b/src/scripts/js/es6/common/vuecomponents/comments/Comment.js new file mode 100644 index 00000000..b25af25e --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/Comment.js @@ -0,0 +1,168 @@ +import '../user/Avatar' +import '../utils/PrettyCreated' +import './CommentEditor' +import './Rating' +import { Linkable } from '../mixins/Linkable' +import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker' +import { EventBus, Events } from './EventBus' + +const TEMPLATE = ` +
+
+
+ +
+
+
+
+
+

+ {{ comment.user.full_name }} +

+ +

+ +

+ +
+ +
+ + Reply + + + Edit + + + Cancel + +
+ +
+
+
+
+ + +
+
+ +
+
+`; + +Vue.component('comment', { + template: TEMPLATE, + mixins: [Linkable, UnitOfWorkTracker], + props: { + user: Object, + comment: Object, + readOnly: { + type: Boolean, + default: false, + }, + isReply: { + type: Boolean, + default: false, + }, + }, + data() { + return { + isReplying: false, + isUpdating: false, + id: this.comment.id, + } + }, + computed: { + canUpdate() { + return !this.readOnly && this.comment.user.id === this.user.user_id && !this.isUpdating && !this.isReplying; + }, + canReply() { + return !this.readOnly && !this.isUpdating && !this.isReplying; + }, + canCancel() { + return this.isReplying || this.isUpdating; + }, + editorMode() { + if(this.isReplying) { + return 'reply'; + } + if(this.isUpdating) { + return 'update'; + } + } + }, + created() { + EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors); + EventBus.$on(Events.EDIT_DONE, this.doHideEditors); + }, + beforeDestroy() { + EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors); + EventBus.$off(Events.EDIT_DONE, this.doHideEditors); + }, + methods: { + showReplyEditor() { + EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id ); + this.isReplying = true; + }, + showUpdateEditor() { + EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id ); + this.isUpdating = true; + }, + cancleEdit() { + this.doHideEditors(); + EventBus.$emit(Events.EDIT_DONE, this.comment.id ); + }, + doHideEditors() { + this.isReplying = false; + this.isUpdating = false; + }, + } +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/comments/CommentEditor.js b/src/scripts/js/es6/common/vuecomponents/comments/CommentEditor.js new file mode 100644 index 00000000..b552d77e --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/CommentEditor.js @@ -0,0 +1,330 @@ +import '../utils/MarkdownPreview' +import './AttachmentEditor' +import './UploadProgress' +import { thenCreateComment, thenUpdateComment } from '../../api/comments' +import { thenUploadFile } from '../../api/files' +import { Droptarget } from '../mixins/Droptarget' +import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker' +import { EventBus, Events } from './EventBus' + +const MAX_ATTACHMENTS = 5; + +const TEMPLATE =` +
+
+ + +
+
+ +
+ +
+
+ +
+`; + +Vue.component('comment-editor', { + template: TEMPLATE, + mixins: [Droptarget, UnitOfWorkTracker], + props: { + user: Object, + parentId: String, + projectId: String, + comment: Object, + mode: { + type: String, + default: 'reply', // reply or update + }, + }, + data() { + return { + msg: this.initialMsg(), + attachments: this.initialAttachments(), + uploads: { + nbrOfActive: 0, + nbrOfTotal: 0, + total: 0, + loaded: 0 + }, + } + }, + computed: { + submitButtonText() { + switch(this.mode) { + case 'reply': return 'Send'; + case 'update': return 'Update'; + default: console.error('Unknown mode: ', this.mode); + } + }, + submitButtonIcon() { + if (this.isBusyWorking) { + return 'pi-spin spin'; + }else{ + switch(this.mode) { + case 'reply': return 'pi-paper-plane'; + case 'update': return 'pi-check'; + default: console.error('Unknown mode: ', this.mode); + } + } + }, + attachmentsAsObject() { + let attachmentsObject = {}; + for (let a of this.attachments) { + attachmentsObject[a.slug] = {oid: a.oid}; + } + return attachmentsObject; + }, + allSlugs() { + return this.attachments.map((a) => { + return a['slug']; + }); + }, + isMsgLongEnough() { + return this.msg.length >= 5; + }, + isAttachmentsValid() { + for (let att of this.attachments) { + if(!att.isSlugValid) { + return false; + } + } + return true; + }, + isValid() { + return this.isAttachmentsValid && this.isMsgLongEnough; + }, + canSubmit() { + return this.isValid && !this.isBusyWorking; + }, + uploadProgressPercent() { + if (this.uploads.nbrOfActive === 0 || this.uploads.total === 0) { + return 100; + } + return this.uploads.loaded / this.uploads.total * 100; + }, + uploadProgressLabel() { + if (this.uploadProgressPercent === 100) { + return 'Processing' + } + if (this.uploads.nbrOfTotal === 1) { + return 'Uploading file'; + } else { + let fileOf = this.uploads.nbrOfTotal - this.uploads.nbrOfActive + 1; + return `Uploading ${fileOf}/${this.uploads.nbrOfTotal} files`; + } + }, + }, + watch:{ + msg(){ + this.autoSizeInputField(); + } + }, + mounted() { + if(this.comment) { + this.$nextTick(function () { + this.autoSizeInputField(); + this.$refs.inputField.focus(); + }) + } + }, + methods: { + initialMsg() { + if (this.comment) { + if (this.mode === 'reply') { + return `***@${this.comment.user.full_name}*** `; + } + if (this.mode === 'update') { + return this.comment.msg_markdown; + } + } + return ''; + }, + initialAttachments() { + // Transforming the attacmentobject to an array of attachments + let attachmentsList = [] + if(this.mode === 'update') { + let attachmentsObj = this.comment.properties.attachments + for (let k in attachmentsObj) { + if (attachmentsObj.hasOwnProperty(k)) { + let a = { + slug: k, + oid: attachmentsObj[k]['oid'], + isSlugValid: true + } + attachmentsList.push(a); + } + } + } + return attachmentsList; + }, + submit() { + if(!this.canSubmit) return; + this.unitOfWork( + this.thenSubmit() + .fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')}) + ); + }, + thenSubmit() { + if (this.mode === 'reply') { + return this.thenCreateComment(); + } else { + return this.thenUpdateComment(); + } + }, + keyUp(e) { + if ((e.keyCode == 13 || e.key === 'Enter') && e.ctrlKey) { + this.submit(); + } + }, + thenCreateComment() { + return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject) + .then((newComment) => { + EventBus.$emit(Events.NEW_COMMENT, newComment); + EventBus.$emit(Events.EDIT_DONE, newComment.id ); + this.cleanUp(); + }) + }, + thenUpdateComment() { + return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject) + .then((updatedComment) => { + EventBus.$emit(Events.UPDATED_COMMENT, updatedComment); + EventBus.$emit(Events.EDIT_DONE, updatedComment.id); + this.cleanUp(); + }) + }, + canHandleDrop(event) { + let dataTransfer = event.dataTransfer; + let items = [...dataTransfer.items]; + let nbrOfAttachments = items.length + this.uploads.nbrOfActive + this.attachments.length; + if(nbrOfAttachments > MAX_ATTACHMENTS) { + // Exceeds the limit + return false; + } + // Only files in drop + return [...dataTransfer.items].reduce((prev, it) => { + let isFile = it.kind === 'file' && !!it.type; + return prev && isFile; + }, !!items.length); + }, + onDrop(event) { + let files = [...event.dataTransfer.files]; + for (let f of files) { + this.unitOfWork( + this.thenUploadFile(f) + .fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'File upload failed')}) + ); + } + }, + thenUploadFile(file){ + let lastReportedTotal = 0; + let lastReportedLoaded = 0; + let progressCB = (total, loaded) => { + this.uploads.loaded += loaded - lastReportedLoaded; + this.uploads.total += total - lastReportedTotal; + lastReportedLoaded = loaded; + lastReportedTotal = total; + } + this.uploads.nbrOfActive++; + this.uploads.nbrOfTotal++; + return thenUploadFile(this.projectId || this.comment.project, file, progressCB) + .then((resp) => { + let attachment = { + slug: file.name, + oid: resp['file_id'], + isSlugValid: false + } + this.attachments.push(attachment); + this.msg += this.getAttachmentMarkdown(attachment); + }) + .always(()=>{ + this.uploads.nbrOfActive--; + if(this.uploads.nbrOfActive === 0) { + this.uploads.loaded = 0; + this.uploads.total = 0; + this.uploads.nbrOfTotal = 0; + } + }) + }, + getAttachmentMarkdown(attachment){ + return `{attachment ${attachment.slug}}`; + }, + insertAttachment(oid){ + let attachment = this.getAttachment(oid); + this.msg += this.getAttachmentMarkdown(attachment); + }, + attachmentDelete(oid) { + let attachment = this.getAttachment(oid); + let markdownToRemove = this.getAttachmentMarkdown(attachment); + this.msg = this.msg.replace(new RegExp(markdownToRemove,'g'), ''); + this.attachments = this.attachments.filter((a) => {return a.oid !== oid}); + }, + attachmentRename(newName, oid) { + let attachment = this.getAttachment(oid); + let oldMarkdownAttachment = this.getAttachmentMarkdown(attachment); + attachment.slug = newName; + let newMarkdownAttachment = this.getAttachmentMarkdown(attachment); + + this.msg = this.msg.replace(new RegExp(oldMarkdownAttachment,'g'), newMarkdownAttachment); + }, + getAttachment(oid) { + for (let a of this.attachments) { + if (a.oid === oid) return a; + } + console.error('No attachment found:', oid); + }, + attachmentValidation(oid, isValid) { + let attachment = this.getAttachment(oid); + attachment.isSlugValid = isValid; + }, + cleanUp() { + this.msg = ''; + this.attachments = []; + }, + autoSizeInputField() { + let elInputField = this.$refs.inputField; + elInputField.style.cssText = 'height:auto; padding:0'; + let newInputHeight = elInputField.scrollHeight + 20; + elInputField.style.cssText = `height:${ newInputHeight }px`; + } + } +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js b/src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js new file mode 100644 index 00000000..bf556366 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js @@ -0,0 +1,157 @@ +import './CommentEditor' +import './Comment' +import './CommentsLocked' +import '../user/Avatar' +import '../utils/GenericPlaceHolder' +import { thenGetComments } from '../../api/comments' +import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker' +import { EventBus, Events } from './EventBus' + +const TEMPLATE = ` +
+
+ + +
+ +
{{ numberOfCommentsStr }}
+
+ +
+ +
+`; + +Vue.component('comments-tree', { + template: TEMPLATE, + mixins: [UnitOfWorkTracker], + props: { + parentId: String, + readOnly: { + type: Boolean, + default: false + } + }, + data() { + return { + replyHidden: false, + nbrOfComments: 0, + projectId: '', + comments: [], + showLoadingPlaceholder: true, + user: pillar.utils.getCurrentUser(), + canPostComments: this.canPostCommentsStr == 'true' + } + }, + computed: { + numberOfCommentsStr() { + let pluralized = this.nbrOfComments === 1 ? 'Comment' : 'Comments' + return `${ this.nbrOfComments } ${ pluralized }`; + }, + isLoggedIn() { + return this.user.is_authenticated; + }, + iSubscriber() { + return this.user.hasCap('subscriber'); + }, + canRenewSubscription() { + return this.user.hasCap('can-renew-subscription'); + }, + canReply() { + return !this.readOnly && !this.replyHidden && this.isLoggedIn; + } + }, + watch: { + isBusyWorking(isBusy) { + if(isBusy) { + $(document).trigger('pillar:workStart'); + } else { + $(document).trigger('pillar:workStop'); + } + } + }, + created() { + EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors); + EventBus.$on(Events.EDIT_DONE, this.showReplyComponent); + EventBus.$on(Events.NEW_COMMENT, this.onNewComment); + EventBus.$on(Events.UPDATED_COMMENT, this.onCommentUpdated); + this.unitOfWork( + thenGetComments(this.parentId) + .then((commentsTree) => { + this.nbrOfComments = commentsTree['nbr_of_comments']; + this.comments = commentsTree['comments']; + this.projectId = commentsTree['project']; + }) + .fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to load comments')}) + .always(()=>this.showLoadingPlaceholder = false) + ); + }, + beforeDestroy() { + EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors); + EventBus.$off(Events.EDIT_DONE, this.showReplyComponent); + EventBus.$off(Events.NEW_COMMENT, this.onNewComment); + EventBus.$off(Events.UPDATED_COMMENT, this.onCommentUpdated); + }, + methods: { + doHideEditors() { + this.replyHidden = true; + }, + showReplyComponent() { + this.replyHidden = false; + }, + onNewComment(newComment) { + this.nbrOfComments++; + let parentArray; + if(newComment.parent === this.parentId) { + parentArray = this.comments; + } else { + let parentComment = this.findComment(this.comments, (comment) => { + return comment.id === newComment.parent; + }); + parentArray = parentComment.replies; + } + parentArray.unshift(newComment); + }, + onCommentUpdated(updatedComment) { + let commentInTree = this.findComment(this.comments, (comment) => { + return comment.id === updatedComment.id; + }); + delete updatedComment.replies; // No need to apply these since they should be the same + Object.assign(commentInTree, updatedComment); + }, + findComment(arrayOfComments, matcherCB) { + for(let comment of arrayOfComments) { + if(matcherCB(comment)) { + return comment; + } + let match = this.findComment(comment.replies, matcherCB); + if (match) { + return match; + } + } + } + }, +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/comments/CommentsLocked.js b/src/scripts/js/es6/common/vuecomponents/comments/CommentsLocked.js new file mode 100644 index 00000000..25b00c85 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/CommentsLocked.js @@ -0,0 +1,53 @@ +const TEMPLATE = ` +
+
+ + Only project members can comment. +
+ +
+ + Join the conversation! + Renew your subscription + to comment. +
+ +
+ + Join the conversation! + Subscribe to Blender Cloud + to comment. +
+ + +
+`; + +Vue.component('comments-locked', { + template: TEMPLATE, + props: {user: Object}, + computed: { + msgToShow() { + if(this.user && this.user.is_authenticated) { + if (this.user.hasCap('subscriber')) { + return 'PROJECT_MEMBERS_ONLY'; + } else if(this.user.hasCap('can-renew-subscription')) { + return 'RENEW'; + } else { + return 'JOIN'; + } + } + return 'LOGIN'; + } + }, +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/comments/EventBus.js b/src/scripts/js/es6/common/vuecomponents/comments/EventBus.js new file mode 100644 index 00000000..b917fadf --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/EventBus.js @@ -0,0 +1,7 @@ +export const Events = { + NEW_COMMENT: 'new-comment', + UPDATED_COMMENT: 'updated-comment', + EDIT_DONE: 'edit-done', + BEFORE_SHOW_EDITOR: 'before-show-editor' +} +export const EventBus = new Vue(); diff --git a/src/scripts/js/es6/common/vuecomponents/comments/Rating.js b/src/scripts/js/es6/common/vuecomponents/comments/Rating.js new file mode 100644 index 00000000..03dd6888 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/Rating.js @@ -0,0 +1,52 @@ +import { EventBus, Events } from './EventBus' +import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker' +import { thenVoteComment } from '../../api/comments' +const TEMPLATE = ` +
+
{{ rating }}
+
+
+`; + +Vue.component('comment-rating', { + template: TEMPLATE, + mixins: [UnitOfWorkTracker], + props: {comment: Object}, + computed: { + positiveRating() { + return this.comment.properties.rating_positive || 0; + }, + negativeRating() { + return this.comment.properties.rating_negative || 0; + }, + rating() { + return this.positiveRating - this.negativeRating; + }, + isPositive() { + return this.rating > 0; + }, + hasRating() { + return (this.positiveRating || this.negativeRating) !== 0; + }, + canVote() { + return this.comment.user.id !== pillar.utils.getCurrentUser().user_id; + } + }, + methods: { + upVote() { + let vote = this.comment.current_user_rating === true ? 0 : 1; // revoke if set + this.unitOfWork( + thenVoteComment(this.comment.parent, this.comment.id, vote) + .then((updatedComment) => { + EventBus.$emit(Events.UPDATED_COMMENT, updatedComment); + }) + .fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Faied to vote on comment')}) + ); + } + } +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/comments/UploadProgress.js b/src/scripts/js/es6/common/vuecomponents/comments/UploadProgress.js new file mode 100644 index 00000000..1dc58f14 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/comments/UploadProgress.js @@ -0,0 +1,23 @@ +const TEMPLATE = ` +
+ + + +
+`; + +Vue.component('upload-progress', { + template: TEMPLATE, + props: { + label: String, + progress: { + type: Number, + default: 0 + } + }, +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/init.js b/src/scripts/js/es6/common/vuecomponents/init.js new file mode 100644 index 00000000..343dfa00 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/init.js @@ -0,0 +1 @@ +import './comments/CommentTree' \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js b/src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js new file mode 100644 index 00000000..6021bafa --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js @@ -0,0 +1,86 @@ +/** + * Vue mixin that makes the component a droptarget + * override canHandleDrop(event) and onDrop(event) + * dragOverClasses can be bound to target class + */ + +var Droptarget = { + data() { + return { + droptargetCounter: 0, + droptargetCanHandle: false + } + }, + computed: { + isDragingOver() { + return this.droptargetCounter > 0; + }, + dropTargetClasses() { + return { + 'drag-hover': this.isDragingOver, + 'unsupported-drop': this.isDragingOver && !this.droptargetCanHandle + } + } + }, + mounted() { + this.$nextTick(function () { + this.$el.addEventListener('dragenter', this._onDragEnter); + this.$el.addEventListener('dragleave', this._onDragLeave); + this.$el.addEventListener('dragend', this._onDragEnd); + this.$el.addEventListener('dragover', this._onDragOver); + this.$el.addEventListener('drop', this._onDrop); + }); + }, + beforeDestroy() { + this.$el.removeEventListener('dragenter', this._onDragEnter); + this.$el.removeEventListener('dragleave', this._onDragLeave); + this.$el.removeEventListener('dragend', this._onDragEnd); + this.$el.removeEventListener('dragover', this._onDragOver); + this.$el.removeEventListener('drop', this._onDrop); + }, + methods: { + canHandleDrop(event) { + throw Error('Not implemented'); + }, + onDrop(event) { + throw Error('Not implemented'); + }, + _onDragEnter(event) { + event.preventDefault(); + event.stopPropagation(); + this.droptargetCounter++; + if(this.droptargetCounter === 1) { + try { + this.droptargetCanHandle = this.canHandleDrop(event); + } catch (error) { + console.warn(error); + this.droptargetCanHandle = false; + } + } + }, + _onDragLeave() { + this.droptargetCounter--; + }, + _onDragEnd() { + this.droptargetCounter = 0; + }, + _onDragOver() { + event.preventDefault(); + event.stopPropagation(); + }, + _onDrop(event) { + event.preventDefault(); + event.stopPropagation(); + if(this.droptargetCanHandle) { + try { + this.onDrop(event); + } catch (error) { + console.console.warn(error); + } + } + this.droptargetCounter = 0; + }, + } +} + +export { Droptarget } \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js b/src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js new file mode 100644 index 00000000..52322374 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js @@ -0,0 +1,24 @@ +/** + * Vue mixin that scrolls element into view if id matches #value in url + * @param {String} id identifier that is set by the user of the mixin + * @param {Boolean} isLinked true if Component is linked + */ +let hash = window.location.hash.substr(1).split('?')[0]; +var Linkable = { + data() { + return { + id: '', + isLinked: false, + } + }, + mounted: function () { + this.$nextTick(function () { + if(hash && this.id === hash) { + this.isLinked = true; + this.$el.scrollIntoView({ behavior: 'smooth' }); + } + }) + } +} + +export { Linkable } \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/mixins/UnitOfWorkTracker.js b/src/scripts/js/es6/common/vuecomponents/mixins/UnitOfWorkTracker.js new file mode 100644 index 00000000..b1deeb4b --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/mixins/UnitOfWorkTracker.js @@ -0,0 +1,59 @@ +/** + * Vue helper mixin to keep track if work is in progress or not. + * Example use: + * Keep track of work in own component: + * this.unitOfWork( + * thenDostuff() + * .then(...) + * .fail(...) + * ); + * + * Keep track of work in child components: + * + * + * Use the information to enable class: + *
+ */ +var UnitOfWorkTracker = { + data() { + return { + unitOfWorkCounter: 0, + } + }, + computed: { + isBusyWorking() { + if(this.unitOfWorkCounter < 0) { + console.error('UnitOfWork missmatch!') + } + return this.unitOfWorkCounter > 0; + } + }, + watch: { + isBusyWorking(isBusy) { + if(isBusy) { + this.$emit('unit-of-work', 1); + } else { + this.$emit('unit-of-work', -1); + } + } + }, + methods: { + unitOfWork(promise) { + this.unitOfWorkBegin(); + return promise.always(this.unitOfWorkDone); + }, + unitOfWorkBegin() { + this.unitOfWorkCounter++; + }, + unitOfWorkDone() { + this.unitOfWorkCounter--; + }, + childUnitOfWork(direction) { + this.unitOfWorkCounter += direction; + } + } +} + +export { UnitOfWorkTracker } \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/user/Avatar.js b/src/scripts/js/es6/common/vuecomponents/user/Avatar.js new file mode 100644 index 00000000..47270078 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/user/Avatar.js @@ -0,0 +1,12 @@ +const TEMPLATE = ` +
+ +
+`; + +Vue.component('user-avatar', { + template: TEMPLATE, + props: {user: Object}, +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/utils/GenericPlaceHolder.js b/src/scripts/js/es6/common/vuecomponents/utils/GenericPlaceHolder.js new file mode 100644 index 00000000..dab9a0c7 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/utils/GenericPlaceHolder.js @@ -0,0 +1,13 @@ +const TEMPLATE = +`
+ + {{ label }} +
+`; + +Vue.component('generic-placeholder', { + template: TEMPLATE, + props: { + label: String, + }, +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/utils/MarkdownPreview.js b/src/scripts/js/es6/common/vuecomponents/utils/MarkdownPreview.js new file mode 100644 index 00000000..aa34c578 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/utils/MarkdownPreview.js @@ -0,0 +1,56 @@ +import { debounced } from '../../utils/init' +import { thenMarkdownToHtml } from '../../api/markdown' +import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker' +const TEMPLATE = ` +
+ +`; + +Vue.component('markdown-preview', { + template: TEMPLATE, + mixins: [UnitOfWorkTracker], + props: { + markdown: String, + attachments: Object + }, + data() { + return { + asHtml: '', + } + }, + created() { + this.markdownToHtml(this.markdown, this.attachments); + this.debouncedMarkdownToHtml = debounced(this.markdownToHtml); + }, + watch: { + markdown(newValue, oldValue) { + this.debouncedMarkdownToHtml(newValue, this.attachments); + }, + attachments(newValue, oldValue) { + this.debouncedMarkdownToHtml(this.markdown, newValue); + } + }, + methods: { + markdownToHtml(markdown, attachments) { + this.unitOfWork( + thenMarkdownToHtml(markdown, attachments) + .then((data) => { + this.asHtml = data.content; + }) + .fail((err) => { + toastr.error(xhrErrorResponseMessage(err), 'Parsing failed'); + }) + ); + } + } +}); \ No newline at end of file diff --git a/src/scripts/js/es6/common/vuecomponents/utils/PrettyCreated.js b/src/scripts/js/es6/common/vuecomponents/utils/PrettyCreated.js new file mode 100644 index 00000000..28aae566 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/utils/PrettyCreated.js @@ -0,0 +1,33 @@ +import { prettyDate } from '../../utils/init' +const TEMPLATE = +`
+ {{ prettyCreated }} + * +
+`; + +Vue.component('pretty-created', { + template: TEMPLATE, + props: { + created: String, + updated: String, + detailed: { + type: Boolean, + default: true + } + }, + computed: { + prettyCreated() { + return prettyDate(this.created, this.detailed); + }, + prettyUpdated() { + return prettyDate(this.updated, this.detailed); + }, + isEdited() { + return this.updated && (this.created !== this.updated) + } + } +}); \ No newline at end of file diff --git a/src/scripts/tutti/2_comments.js b/src/scripts/tutti/2_comments.js deleted file mode 100644 index 40d9340a..00000000 --- a/src/scripts/tutti/2_comments.js +++ /dev/null @@ -1,323 +0,0 @@ - -/* Reply */ -$(document).on('click','body .comment-action-reply',function(e){ - e.preventDefault(); - - // container of the comment we are replying to - var parentDiv = $(this).closest('.comment-container'); - - // container of the first-level comment in the thread - var parentDivFirst = parentDiv.prevAll('.is-first:first'); - - // Get the id of the comment - if (parentDiv.hasClass('is-reply')) { - parentNodeId = parentDivFirst.data('node-id'); - } else { - 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.dataset.originalParentId = commentField.dataset.parentId; - commentField.dataset.parentId = parentNodeId; - - // Start the comment field with @authorname: - var replyAuthor = parentDiv.find('.comment-author:first').html(); - - $(commentField).val("**@" + replyAuthor + ":** "); - - // Add class for styling - $('.comment-container').removeClass('is-replying'); - parentDiv.addClass('is-replying'); - - // Move comment-reply container field after the parent container - var commentForm = $('.comment-reply-container').detach(); - parentDiv.after(commentForm); - // document.getElementById('comment_field').focus(); - $(commentField).focus(); - $('.comment-reply-field').addClass('filled'); -}); - - -/* Cancel Reply */ -$(document).on('click','body .comment-action-cancel',function(e){ - e.preventDefault(); - - $('.comment-reply-container').detach().prependTo('#comments-list'); - var commentField = document.getElementById('comment_field'); - commentField.dataset.parentId = commentField.dataset.originalParentId; - delete commentField.dataset.originalParentId; - - $(commentField).val(''); - - $('.comment-reply-field').removeClass('filled'); - $('.comment-container').removeClass('is-replying'); -}); - - -/* Rate */ -$(document).on('click','body .comment-action-rating',function(e){ - e.preventDefault(); - - var $this = $(this); - var nodeId = $this.closest('.comment-container').data('node-id'); - var is_positive = !$this.hasClass('down'); - var parentDiv = $this.parent(); - var rated_positive = parentDiv.hasClass('positive'); - - if (typeof nodeId === 'undefined') { - if (console) console.log('Undefined node ID'); - return; - } - - var op; - if (parentDiv.hasClass('rated') && is_positive == rated_positive) { - op = 'revoke'; - } else if (is_positive) { - op = 'upvote'; - } else { - op = 'downvote'; - } - - $.post("/nodes/comments/" + nodeId + "/rate/" + op) - .done(function(data){ - - // Add/remove styles for rated statuses - switch(op) { - case 'revoke': - parentDiv.removeClass('rated'); - break; - case 'upvote': - parentDiv.addClass('rated'); - parentDiv.addClass('positive'); - break; - case 'downvote': - parentDiv.addClass('rated'); - parentDiv.removeClass('positive'); - break; - } - - var rating = data['data']['rating_positive'] - data['data']['rating_negative']; - $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) -{ - var commentsContainer = $('#comments-embed'); - - return $.get(commentsUrl) - .done(function(dataHtml) { - // Update the DOM injecting the generate HTML into the page - commentsContainer.html(dataHtml); - $('body').trigger('pillar:comments-loaded'); - }) - .fail(function(xhr) { - toastr.error('Could not load comments', xhr.responseText); - - commentsContainer.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('error'); - $textarea.addClass('error'); - $button.html(msg); - - setTimeout(function(){ - $button.html(' Send'); - $button.removeClass('error'); - $textarea.removeClass('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(); - - $container.find('.comment-body').removeClass('editing'); - } -} - -/** - * Return UI to normal, when cancelling or saving. - * - * clicked_item: save/cancel button. - * - * Returns a promise on the comment loading if reload_comment=true. - */ -function commentEditCancel(clicked_item, reload_comment) { - comment_mode(clicked_item, 'view'); - - var comment_container = $(clicked_item).closest('.comment-container'); - var comment_id = comment_container.data('node-id'); - - if (!reload_comment) return; - - return loadComment(comment_id, {'properties.content': 1}) - .done(function(data) { - var comment_html = data['properties']['content_html']; - comment_container - .find('.comment-body span') - .removeClass('editing') - .html(comment_html); - }) - .fail(function(xhr) { - if (console) console.log('Error fetching comment: ', xhr); - toastr.error('Error canceling', xhr.responseText); - }); -} - -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(resp) { - promise.resolve(commentId, resp.data.content_html); - }); - } - - return promise; -} - -/* Used when clicking the .comment-action-submit button or by a shortcut */ -function post_comment($submit_button){ - - save_comment(true) - .progress(function() { - $submit_button - .addClass('submitting') - .html(' Sending...'); - }) - .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 posting comment: ', xhr.responseText); - show_comment_button_error("Try again?"); - } - toastr.error(xhr.responseText, 'Error posting comment'); - }) - .done(function(comment_node_id) { - $submit_button - .removeClass('submitting') - .html(' Send'); - $('#comment_field').val(''); - $('body').trigger('pillar:comment-posted', [comment_node_id]); - - toastr.success('Comment posted!'); - }); -} diff --git a/src/styles/_comments.sass b/src/styles/_comments.sass index 68062966..49789888 100644 --- a/src/styles/_comments.sass +++ b/src/styles/_comments.sass @@ -1,410 +1,360 @@ $comments-width-max: 710px -.comments-container +.comments-tree max-width: $comments-width-max position: relative width: 100% - #comments-reload - text-align: center - cursor: pointer + .comments-list-title + padding: 15px 0 10px 0 + font-size: 1.1em + font-weight: 300 + color: rgba($color-text, .5) + + /* Each comment on the list*/ + .comment-container, + .comment-reply-container + display: flex + position: relative padding: 15px 0 - display: block + transition: background-color 150ms ease-in-out, padding 150ms ease-in-out, margin 150ms ease-in-out + + &.comment-linked + background-color: $color-background-light !important + box-shadow: inset 3px 0 0 $color-info + padding-right: 20px - .comments-list - &-loading - +spin - color: $color-background - font-size: 2em - margin-bottom: 10px - position: relative - text-align: center - top: 25px + &:before + bottom: 25px + color: $color-info + content: 'Linked Comment' + font-size: .8em + position: absolute + right: 20px + text-transform: uppercase - &-title - padding: 15px 0 10px 0 - font-size: 1.1em - font-weight: 300 - color: rgba($color-text, .5) + p.comment-author + color: $color-text-dark + display: inline-block + float: left + font-weight: bold + margin-right: 8px + white-space: nowrap - /* Each comment on the list*/ - .comment-container, - .comment-reply-container - display: flex - position: relative - padding: 15px 0 - transition: background-color 150ms ease-in-out, padding 150ms ease-in-out, margin 150ms ease-in-out + &.op + color: $color-primary-dark - &.comment-linked - background-color: $color-background-light !important - box-shadow: inset 3px 0 0 $color-info - padding-right: 20px + .pretty-created + padding-left: 10px + margin-left: 10px + color: $color-text-dark-hint - &:before - bottom: 25px - color: $color-info - content: 'Linked Comment' - font-size: .8em - position: absolute - right: 20px - text-transform: uppercase + &:before + content: '·' + position: relative + left: -10px + font-weight: 600 - &.is-replying - margin-bottom: 15px !important + /* The actual comment body. */ + /* Here we style both the preview comment and posted comments */ + .comment-content, + .comment-reply-form + +node-details-description + +media-xs + max-width: 100% + +media-sm + max-width: 100% + +media-md + max-width: $comments-width-max + +media-lg + max-width: $comments-width-max - .comment-avatar - padding-right: 5px - padding-left: 5px + border: thin solid transparent + color: darken($color-text, 10%) + font: + size: 1em + weight: normal + margin: 0 + transition: background-color 200ms ease-in-out, margin 200ms ease-in-out - .comment-avatar - padding-right: 10px + +media-xs + padding-left: 0 + font-size: $font-size - img - border-radius: 50% - height: 24px - margin-top: 5px - width: 24px - - p.comment-author - color: $color-text-dark - display: inline-block - float: left - font-weight: bold - margin-right: 8px - white-space: nowrap - - &.op - color: $color-primary-dark - - .comment-time - padding-left: 10px - margin-left: 10px - color: $color-text-dark-hint - - &:before - content: '·' - position: relative - left: -10px - font-weight: 600 - - /* The actual comment body. */ - /* Here we style both the preview comment and posted comments */ - .comment-content, - .comment-reply-form - +node-details-description - +media-xs - max-width: 100% - +media-sm - max-width: 100% - +media-md - max-width: $comments-width-max - +media-lg - max-width: $comments-width-max - - border: thin solid transparent - color: darken($color-text, 10%) - font: - size: 1em - weight: normal - margin: 0 - transition: background-color 200ms ease-in-out, margin 200ms ease-in-out - - +media-xs - padding-left: 0 - font-size: $font-size - - p - +media-xs - padding: - left: 0 - right: 0 - - line-height: 1.4em - margin-top: 5px - - &:last-child - margin-bottom: 10px - - &.comment-author - margin-bottom: 0 - - img.emoji - display: inline-block - padding-top: inherit - padding-bottom: inherit - - .editing - background-color: $color-background-light - display: flex - margin: 30px 0 10px - - textarea - background-color: $color-background - border-radius: 3px - border: none - box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 30%), .5) - display: block - padding: 10px - width: 100% - - &:focus - box-shadow: inset 0 0 2px 0 darken($color-background-dark, 30%) - - .comment-content - display: flex - flex-direction: column - padding-bottom: 0 - width: 100% - - /* Rating, and actions such as reply */ - .comment-meta + p +media-xs padding: left: 0 right: 0 - align-items: center - color: $color-text-dark-secondary - display: flex - font-size: .9em + line-height: 1.4em + margin-top: 5px - /* Small container for rating buttons and value */ - .comment-rating - display: flex - align-items: center + &:last-child + margin-bottom: 10px - &.rated - color: $color-text-dark-secondary - .down - color: $color-downvote + &.comment-author + margin-bottom: 0 - &.rated.positive - color: $color-upvote - .down - color: $color-text-dark-secondary - - .comment-action-rating.up:before - content: '\e83f' - - - .comment-rating-value - padding-right: 15px - color: $color-text-dark-secondary - cursor: default - - .comment-action-rating - cursor: pointer - font-family: 'pillar-font' - height: 25px - width: 16px - - .comment-action-rating.up - &:hover - color: $color-upvote - &:before - content: '\e83e' - top: 2px - position: relative - - .comment-action-rating.down - &:hover - color: $color-downvote - &:before - content: '\e838' - - /* Reply button */ - .comment-action-reply - color: $color-primary - - &:hover - text-decoration: underline - - .comment-action-reply, - .comment-action-edit - padding-left: 10px - margin-left: 10px - - &:before - color: $color-text-dark-secondary - content: '·' - font-weight: 600 - left: -10px - position: relative - text-decoration: none - - span - cursor: pointer - &:hover - color: $color-primary - - span.edit_save, - color: $color-success - display: none - &:hover - color: lighten($color-success, 10%) - - &.error - color: $color-danger - - &.saving - user-select: none - pointer-events: none - cursor: default - - i - font-size: .8em - margin-right: 5px - - span.edit_cancel - display: none - margin-left: 15px - - &.is-reply - padding: - left: 20px - top: 5px - margin-left: 35px - box-shadow: inset 3px 0 0 $color-background - - +media-xs - padding-left: 15px - - &.comment-linked - box-shadow: inset 3px 0 0 $color-info - - &.is-replying+.comment-reply-container - margin-left: 35px - padding-left: 25px - - &.is-first - border-top: 1px solid $color-background - - &.is-team - .comment-author - color: $color-success - - &.is-replying - box-shadow: -5px 0 0 $color-primary - @extend .pl-3 - - &.is-replying+.comment-reply-container - box-shadow: -5px 0 0 $color-primary - margin-left: 0 - padding-left: 55px - - .comment-badge - border-radius: 3px - border: 1px solid $color-text-dark-hint - color: $color-text-dark-hint + img.emoji display: inline-block - font: - size: .7em - weight: 400 - margin: 0 5px 0 10px - padding: 1px 4px - text-transform: uppercase + padding-top: inherit + padding-bottom: inherit - &.badge-team - border-color: $color-info - color: $color-info - &.badge-op - border-color: $color-primary - color: $color-primary - &.badge-own - border-color: $color-success - color: $color-success - -.comment-reply - /* Little gravatar icon on the left */ - &-avatar - img - border-radius: 50% - width: 25px - height: 25px - box-shadow: 0 0 0 3px $color-background-light - - /* textarea field, submit button and reply details */ - &-form - padding: - top: 0 - left: 10px - width: 100% - - &-field - background-color: $color-background-light - border-radius: 3px - box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5) - display: flex - position: relative - transition: border-color 300ms ease-in-out - - textarea - +node-details-description - background-color: $color-background-light - border-bottom-right-radius: 0 - border-top-right-radius: 0 - border: none - box-shadow: none - color: $color-text-dark - flex: 1 - font: - size: 1em - weight: normal - line-height: 1.5em - margin: 0 - min-height: 35px - padding: 10px 0 10px 10px - resize: vertical - transition: box-shadow 250ms ease-in-out + .comment-content + display: flex + flex-direction: column + padding-bottom: 0 width: 100% - &:focus - box-shadow: inset 2px 0 0 0 $color-success, inset 0 2px 0 0 $color-success, inset 0 -2px 0 0 $color-success - border: none - color: $color-text-dark - outline: none + &.is-reply + padding: + left: 20px + top: 5px + margin-left: 35px + box-shadow: inset 3px 0 0 $color-background - &+.comment-reply-meta button.comment-action-submit - box-shadow: inset -2px 0 0 0 $color-success, inset 0 2px 0 0 $color-success, inset 0 -2px 0 0 $color-success + +media-xs + padding-left: 15px + &.comment-linked + box-shadow: inset 3px 0 0 $color-info + + &.is-first + border-top: 1px solid $color-background + + /* Rating, and actions such as reply */ + .comment-meta + +media-xs + padding: + left: 0 + right: 0 + + align-items: center + color: $color-text-dark-secondary + display: flex + font-size: .9em + + /* Small container for rating buttons and value */ + .comment-rating + display: flex + align-items: center + + &.rated + color: $color-text-dark-secondary + .down + color: $color-downvote + + &.rated.positive + color: $color-upvote + .down + color: $color-text-dark-secondary + + .comment-action-rating.up:before + content: '\e83f' // Heart filled + + + .comment-rating-value + padding-right: 15px + color: $color-text-dark-secondary + cursor: default + + .comment-action-rating + cursor: pointer + font-family: 'pillar-font' + height: 25px + width: 16px + + .comment-action-rating.up + &:hover + color: $color-upvote + &:before + content: '\e83e' // Heart outlined + top: 2px + position: relative + + .comment-action + color: $color-primary + padding-left: 10px + margin-left: 10px + + .action + cursor: pointer + margin-left: 15px + &:hover + color: $color-primary + text-decoration: underline + + &:before + color: $color-text-dark-secondary + content: '·' + font-weight: 600 + left: -10px + position: relative + text-decoration: none + + .attachments + display: flex + flex-direction: column + .attachment + display: flex + flex-direction: row + align-items: center + + .preview-thumbnail + margin: 0.1em + + .actions + margin-left: auto + .action + cursor: pointer + color: $color-primary + margin-left: 2em + &.delete + color: $color-danger + input + max-height: 2em + &.error - box-shadow: inset 2px 0 0 0 $color-danger, inset 0 2px 0 0 $color-danger, inset 0 -2px 0 0 $color-danger + input + border-color: $color-danger - &+.comment-reply-meta button.comment-action-submit - box-shadow: inset -2px 0 0 0 $color-danger, inset 0 2px 0 0 $color-danger, inset 0 -2px 0 0 $color-danger + .comments-locked + display: block + padding: 10px + color: $color-text-dark-primary + cursor: default + + .comment-reply + /* textarea field, submit button and reply details */ + &-form + padding: + top: 0 + left: 10px + width: 100% + + &-field + background-color: $color-background-light + border-radius: 3px + box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 20%), .5) + display: flex + position: relative + transition: border-color 300ms ease-in-out - &.filled textarea - border-bottom: thin solid $color-background + +node-details-description + background-color: $color-background-light + border-bottom-right-radius: 0 + border-top-right-radius: 0 + border: none + box-shadow: none + color: $color-text-dark + flex: 1 + font: + size: 1em + weight: normal + line-height: 1.5em + margin: 0 + min-height: 35px + padding: 10px 0 10px 10px + resize: none + overflow: hidden + transition: box-shadow 250ms ease-in-out + width: 100% &:focus - border-bottom-left-radius: 0 + box-shadow: inset 2px 0 0 0 $color-success, inset 0 2px 0 0 $color-success, inset 0 -2px 0 0 $color-success + border: none + color: $color-text-dark + outline: none - &+.comment-reply-preview + &+.comment-reply-meta button.comment-action-submit + box-shadow: inset -2px 0 0 0 $color-success, inset 0 2px 0 0 $color-success, inset 0 -2px 0 0 $color-success + + &.error + box-shadow: inset 2px 0 0 0 $color-danger, inset 0 2px 0 0 $color-danger, inset 0 -2px 0 0 $color-danger + + &+.comment-reply-meta button.comment-action-submit + box-shadow: inset -2px 0 0 0 $color-danger, inset 0 2px 0 0 $color-danger, inset 0 -2px 0 0 $color-danger + + &.filled + textarea + border-bottom: thin solid $color-background + + &:focus + border-bottom-left-radius: 0 + + .comment-reply-meta + background-color: $color-success + + .comment-action-submit + color: white + border-bottom-right-radius: 0 + + span.hotkey + display: block + + .comment-action + .action + cursor: pointer + padding: 10px + text-decoration: underline + + &:hover + color: $color-primary + + &-meta + display: flex + align-items: center + border-bottom-right-radius: 3px + border-top-right-radius: 3px + transition: background-color 150ms ease-in-out, color 150ms ease-in-out + width: 100px + + // The actual button for submitting the comment. + button.comment-action-submit + align-items: center + background: transparent + border: none + border-top-left-radius: 0 + border-bottom-left-radius: 0 + color: $color-success + cursor: pointer display: flex + justify-content: center + flex-direction: column + height: 100% + position: relative + transition: all 200ms ease-in-out + white-space: nowrap + width: 100% - .comment-reply-meta - background-color: $color-success + &:hover + background: rgba($color-success, .1) - .comment-action-submit + &:focus + background: lighten($color-success, 10%) + color: $white + + &.submitting + color: $color-info + + &.error + background-color: $color-danger color: white - border-bottom-right-radius: 0 - span.hotkey - display: block + span.hotkey + color: white + display: none + font-weight: normal + font-size: .9em - &.sign-in - display: block - padding: 10px - color: $color-text-dark-primary - cursor: default - - &-preview + .markdown-preview background-color: $color-background-light border-bottom-left-radius: 3px border-bottom-right-radius: 3px box-shadow: 1px 2px 2px rgba($color-background-dark, .5) - display: none // flex when comment-reply-field has .filled class + display: flex position: relative transition: all 150ms ease-in-out @@ -416,6 +366,15 @@ $comments-width-max: 710px flex: 1 padding: 5px 10px + &-info + background-color: $color-background-dark + display: flex + flex-direction: column + font-size: .8em + padding-bottom: 10px + text-align: center + width: 100px + &:empty color: transparent margin: 0 auto @@ -426,93 +385,43 @@ $comments-width-max: 710px content: '' color: transparent - &-info - background-color: $color-background-dark - display: flex - flex-direction: column - font-size: .8em - padding-bottom: 10px - text-align: center - width: 100px + .user-badges ul.blender-id-badges + list-style: none + padding: 0 + margin: 4px 0 0 0 - .comment-action-cancel - cursor: pointer - padding: 10px - text-decoration: underline + li + margin: 2px 0 !important - &:hover - color: $color-primary + li, li a, li img + padding: 0 !important + li + display: inline + img + width: 16px + height: 16px - &-meta - display: flex - align-items: center - border-bottom-right-radius: 3px - border-top-right-radius: 3px - transition: background-color 150ms ease-in-out, color 150ms ease-in-out - width: 100px + .user-avatar + img + border-radius: 50% + width: 2em + height: 2em + box-shadow: 0 0 0 0.2em $color-background-light - // The actual button for submitting the comment. - button.comment-action-submit - align-items: center - background: transparent - border: none - border-top-left-radius: 0 - border-bottom-left-radius: 0 - color: $color-success - cursor: pointer - display: flex - justify-content: center - flex-direction: column + .drag-hover + &:before + background-color: $color-success + content: " " + display: block height: 100% - position: relative - transition: all 200ms ease-in-out - white-space: nowrap + position: absolute + top: 0 + left: 0 width: 100% + z-index: $z-index-base + 1 + opacity: 0.2 + border-radius: 1em - &:hover - background: rgba($color-success, .1) - - &:focus - background: lighten($color-success, 10%) - color: $white - - &.submitting - color: $color-info - - &.error + &.unsupported-drop + &:before background-color: $color-danger - color: white - - span.hotkey - color: white - display: none - font-weight: normal - font-size: .9em - - -/* Style the comment container when we're replying */ -.comment-container + .comment-reply-container - margin-left: 30px - padding-top: 10px - - .comment-reply-form - .comment-reply-meta - button.comment-action-cancel - display: inline-block - - -.comment-badges ul.blender-id-badges - list-style: none - padding: 0 - margin: 4px 0 0 0 - - li - margin: 2px 0 !important - - li, li a, li img - padding: 0 !important - li - display: inline - img - width: 16px - height: 16px diff --git a/src/styles/_utils.sass b/src/styles/_utils.sass index cfb0a2bc..28ffd988 100644 --- a/src/styles/_utils.sass +++ b/src/styles/_utils.sass @@ -415,7 +415,8 @@ ul li img @extend .d-block @extend .mx-auto - @extend .my-3 + margin-top: 1rem + margin-bottom: 1rem max-width: 100% &.emoji diff --git a/src/templates/layout.pug b/src/templates/layout.pug index 3bb5029a..361e5c17 100644 --- a/src/templates/layout.pug +++ b/src/templates/layout.pug @@ -30,6 +30,8 @@ html(lang="en") | {% endblock %} script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}") + script. + pillar.utils.initCurrentUser({{ current_user | json | safe }}); script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}") | {% if current_user.is_authenticated %} diff --git a/src/templates/nodes/custom/_scripts.pug b/src/templates/nodes/custom/_scripts.pug index 1bd93590..bf35453e 100644 --- a/src/templates/nodes/custom/_scripts.pug +++ b/src/templates/nodes/custom/_scripts.pug @@ -56,8 +56,7 @@ script(type="text/javascript"). } if (ProjectUtils.nodeType() == 'asset' || ProjectUtils.nodeType() == 'post') { - var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node._id) }}"; - loadComments(commentsUrl); + new Vue({el:'#comments-embed'}); } {% if node.has_method('PUT') %} diff --git a/src/templates/nodes/custom/asset/view_theatre_embed.pug b/src/templates/nodes/custom/asset/view_theatre_embed.pug index d32bb502..fdc350dd 100644 --- a/src/templates/nodes/custom/asset/view_theatre_embed.pug +++ b/src/templates/nodes/custom/asset/view_theatre_embed.pug @@ -42,7 +42,9 @@ a(href="{{ node.short_link }}") {{ node.short_link }} | {% endif %} - #comments-embed + comments-tree#comments-embed( + parent-id="{{ node._id }}" + ) include ../_scripts diff --git a/src/templates/nodes/custom/comment/_macros.pug b/src/templates/nodes/custom/comment/_macros.pug deleted file mode 100644 index 6e39647c..00000000 --- a/src/templates/nodes/custom/comment/_macros.pug +++ /dev/null @@ -1,51 +0,0 @@ -| {%- 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-avatar - img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}") - .comment-badges - | {{ comment._user.badges_html|safe }} - .comment-content - .comment-body - p.comment-author {{ comment._user.full_name }} - span {{comment.properties | markdowned('content') }} - - | {# TODO(Pablo): Markdown preview when editing #} - - .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') }}") {{ comment._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 %} - - .comment-time(title='{{ comment._created }}') - | {{ comment._created | pretty_date_time }} - | {% if comment._created != comment._updated %} - span(title="{{ _('edited') }} {{ comment._updated | pretty_date_time }}") * - | {% endif %} - -| {% for reply in comment['_replies']['_items'] %} -| {{ render_comment(reply, True) }} -| {% endfor %} -| {%- endmacro -%} diff --git a/src/templates/nodes/custom/comment/list_embed.pug b/src/templates/nodes/custom/comment/list_embed.pug deleted file mode 100644 index 43a380c5..00000000 --- a/src/templates/nodes/custom/comment/list_embed.pug +++ /dev/null @@ -1 +0,0 @@ -| {% extends "nodes/custom/comment/list_embed_base.html" %} diff --git a/src/templates/nodes/custom/comment/list_embed_base.pug b/src/templates/nodes/custom/comment/list_embed_base.pug deleted file mode 100644 index 90ddf312..00000000 --- a/src/templates/nodes/custom/comment/list_embed_base.pug +++ /dev/null @@ -1,258 +0,0 @@ -| {% import 'nodes/custom/comment/_macros.html' as macros %} -#comments.comments-container - section#comments-list.comments-list - .comment-reply-container - | {% if can_post_comments %} - | {% block can_post_comment %} - .comment-reply-avatar - img( - title="{{ _('Commenting as') }} {{ current_user.full_name }}", - 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 - - button.comment-action-submit( - id="comment_submit", - type="button", - title="Post Comment (Ctrl+Enter)") - span - i.pi-paper-plane - | {{ _('Send') }} - span.hotkey Ctrl + Enter - - .comment-reply-preview - .comment-reply-preview-md - .comment-reply-info - .comment-action-cancel( - title="{{ _('cancel') }}") - span {{ _('cancel') }} - - a( - title="{{ _('Handy guide of Markdown syntax') }}", - target="_blank", - href="http://commonmark.org/help/") - span {{ _('markdown cheatsheet') }} - | {% endblock can_post_comment %} - - | {% else %} - | {% block can_not_post_comment %} - | {% if current_user.is_authenticated %} - - | {# * User is authenticated, but has no subscription or 'POST' permission #} - .comment-reply-form - .comment-reply-field.sign-in - | {% if current_user.has_cap('subscriber') %} - i.pi-lock - | Only project members can comment. - | {% elif current_user.has_cap('can-renew-subscription') %} - i.pi-heart - | Join the conversation! #[a(href='/renew', target='_blank') Renew your subscription] to comment. - | {% else %} - | Join the conversation! #[a(href="https://store.blender.org/product/membership/") Subscribe to Blender Cloud] to comment. - | {% endif %} - - | {% else %} - | {# * User is not autenticated #} - .comment-reply-form - .comment-reply-field.sign-in - a(href="{{ url_for('users.login') }}") {{ _('Log in to comment') }} - | {% endif %} - | {% endblock can_not_post_comment %} - | {% endif %} - - | {% if show_comments and (nr_of_comments > 0) %} - section.comments-list-header - .comments-list-title - | {{ nr_of_comments }} {{ _('comment') }}{{ nr_of_comments|pluralize }} - .comments-list-items - | {% for comment in comments['_items'] %} - | {{ macros.render_comment(comment, False) }} - | {% endfor %} - | {% endif %} - -| {% block comment_scripts %} -script. - - {% if show_comments %} - $('body') - .off('pillar:comment-posted') - .on('pillar:comment-posted', function(e, comment_node_id) { - - var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node_id) }}"; - - loadComments(commentsUrl) - .done(function() { - $('#' + comment_node_id).scrollHere(); - }); - }); - - // If there's a comment link in the URL, scroll there - function scrollToLinkedComment() { - var scrollToId = location.hash; - - // Check that it's a valid ObjectID before passing it to jQuery. - if (!/^[a-fA-F0-9]{24}$/.test(scrollToId.replace('#',''))) return; - - $(scrollToId) - .addClass('comment-linked') - .scrollHere(); - } - - $(scrollToLinkedComment); - - {% endif %} - - - {% if can_post_comments %} - - // If we can actually comment, load the tools to do it - - // Submit new comment - $(document) - .off('click','body .comment-action-submit') - .on( 'click','body .comment-action-submit', function(e){ - post_comment($(this)); - }); - - // Writing comment - var $commentField = $("#comment_field"); - var $commentContainer = $commentField.parent(); - var $commentPreview = $commentField.parent().parent().find('.comment-reply-preview-md'); - - function parseCommentContent(content) { - - $.ajax({ - url: "{{ url_for('nodes.preview_markdown')}}", - type: 'post', - data: {content: content}, - headers: {"X-CSRFToken": csrf_token}, - headers: {}, - dataType: 'json' - }) - .done(function (data) { - $commentPreview.html(data.content); - }) - .fail(function (err) { - toastr.error(xhrErrorResponseMessage(err), 'Parsing failed'); - }); - } - - var options = { - callback: parseCommentContent, - wait: 750, - highlight: false, - allowSubmit: false, - captureLength: 2 - } - - $commentField.typeWatch(options); - - $(document) - .off('keyup','body .comment-reply-field textarea') - .on( 'keyup','body .comment-reply-field textarea',function(e){ - - // While we are at it, style if empty - if ($commentField.val()) { - $commentContainer.addClass('filled'); - } else { - $commentContainer.removeClass('filled'); - } - - // Send on ctrl+enter - if ($commentField.is(":focus")) { - if ((e.keyCode == 10 || e.keyCode == 13) && e.ctrlKey){ - post_comment($commentContainer.find('.comment-action-submit')); - } - } - }); - - // Autoresize the textarea as we type - $('#comment_field').autoResize(); - - - // Edit comment - // Enter edit mode - $(document) - .off('click','body .comment-action-edit span.edit_mode') - .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-body span'); - var height = comment_content.height(); - - loadComment(comment_id, {'properties.content': 1}) - - .done(function(data) { - var comment_raw = data['properties']['content']; - comment_content.html($('