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
This commit is contained in:
2018-12-12 11:45:47 +01:00
parent bba1448acd
commit fbcce7a6d8
45 changed files with 2248 additions and 1477 deletions

View File

@@ -40,7 +40,8 @@ let destination = {
let source = { let source = {
bootstrap: 'node_modules/bootstrap/', bootstrap: 'node_modules/bootstrap/',
jquery: 'node_modules/jquery/', jquery: 'node_modules/jquery/',
popper: 'node_modules/popper.js/' popper: 'node_modules/popper.js/',
vue: 'node_modules/vue/',
} }
/* Stylesheets */ /* Stylesheets */
@@ -135,6 +136,7 @@ gulp.task('scripts_concat_tutti', function(done) {
let toUglify = [ let toUglify = [
source.jquery + 'dist/jquery.min.js', source.jquery + 'dist/jquery.min.js',
source.vue + 'dist/vue.min.js',
source.popper + 'dist/umd/popper.min.js', source.popper + 'dist/umd/popper.min.js',
source.bootstrap + 'js/dist/index.js', source.bootstrap + 'js/dist/index.js',
source.bootstrap + 'js/dist/util.js', source.bootstrap + 'js/dist/util.js',

View File

@@ -38,7 +38,8 @@
"glob": "7.1.3", "glob": "7.1.3",
"jquery": "3.3.1", "jquery": "3.3.1",
"popper.js": "1.14.4", "popper.js": "1.14.4",
"video.js": "7.2.2" "video.js": "7.2.2",
"vue": "2.5.17"
}, },
"scripts": { "scripts": {
"test": "jest" "test": "jest"

View File

@@ -1,3 +1,5 @@
from pillar.api.node_types import attachments_embedded_schema
node_type_comment = { node_type_comment = {
'name': 'comment', 'name': 'comment',
'description': 'Comments for asset nodes, pages, etc.', 'description': 'Comments for asset nodes, pages, etc.',
@@ -51,7 +53,8 @@ node_type_comment = {
} }
}, },
'confidence': {'type': 'float'}, 'confidence': {'type': 'float'},
'is_reply': {'type': 'boolean'} 'is_reply': {'type': 'boolean'},
'attachments': attachments_embedded_schema,
}, },
'form_schema': {}, 'form_schema': {},
'parent': ['asset', 'comment'], 'parent': ['asset', 'comment'],

View File

@@ -6,14 +6,14 @@ import pymongo.errors
import werkzeug.exceptions as wz_exceptions import werkzeug.exceptions as wz_exceptions
from flask import current_app, Blueprint, request 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 import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login from pillar.api.utils.authorization import check_permissions, require_login
from pillar.web.utils import pretty_date from pillar.web.utils import pretty_date
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
blueprint = Blueprint('nodes_api', __name__) blueprint = Blueprint('nodes_api', __name__)
ROLES_FOR_SHARING = {'subscriber', 'demo'} ROLES_FOR_SHARING = ROLES_FOR_COMMENTING ={'subscriber', 'demo'}
@blueprint.route('/<node_id>/share', methods=['GET', 'POST']) @blueprint.route('/<node_id>/share', methods=['GET', 'POST'])
@@ -51,6 +51,41 @@ def share_node(node_id):
return jsonify(eve_hooks.short_link_info(short_code), status=status) return jsonify(eve_hooks.short_link_info(short_code), status=status)
@blueprint.route('/<string(length=24):node_path>/comments', methods=['GET'])
def get_node_comments(node_path: str):
node_id = str2id(node_path)
return comments.get_node_comments(node_id)
@blueprint.route('/<string(length=24):node_path>/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('/<string(length=24):node_path>/comments/<string(length=24):comment_path>', 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('/<string(length=24):node_path>/comments/<string(length=24):comment_path>/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/')
@blueprint.route('/tagged/<tag>') @blueprint.route('/tagged/<tag>')
def tagged(tag=''): def tagged(tag=''):

View File

@@ -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

View File

@@ -5,7 +5,7 @@ import logging
from flask import current_app from flask import current_app
import werkzeug.exceptions as wz_exceptions 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 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 & # we can pass this stuff to Eve's patch_internal; that way the validation &
# authorisation system has enough info to work. # authorisation system has enough info to work.
nodes_coll = current_app.data.driver.db['nodes'] nodes_coll = current_app.data.driver.db['nodes']
projection = {'user': 1, node = nodes_coll.find_one(node_id)
'project': 1,
'node_type': 1}
node = nodes_coll.find_one(node_id, projection=projection)
if node is None: if node is None:
log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id)) 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) 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'): if node['user'] != user_id and not authorization.user_has_role('admin'):
raise wz_exceptions.Forbidden('You can only edit your own comments.') raise wz_exceptions.Forbidden('You can only edit your own comments.')
# Use Eve to PATCH this node, as that also updates the etag. node = remove_private_keys(node)
r, _, _, status = current_app.patch_internal('nodes', node['properties']['content'] = patch['content']
{'properties.content': patch['content'], node['properties']['attachments'] = patch.get('attachments', {})
'project': node['project'], # Use Eve to PUT this node, as that also updates the etag and we want to replace attachments.
'user': node['user'], r, _, _, status = current_app.put_internal('nodes',
'node_type': node['node_type']}, node,
concurrency_check=False, concurrency_check=False,
_id=node_id) _id=node_id)
if status != 200: if status != 200:
log.error('Error %i editing comment %s for user %s: %s', log.error('Error %i editing comment %s for user %s: %s',
status, node_id, user_id, r) status, node_id, user_id, r)

View File

@@ -14,6 +14,7 @@ import werkzeug.exceptions as wz_exceptions
import pillarsdk import pillarsdk
import pillar.api.utils import pillar.api.utils
from pillar import auth
from pillar.api.utils import pretty_duration from pillar.api.utils import pretty_duration
from pillar.web.utils import pretty_date from pillar.web.utils import pretty_date
from pillar.web.nodes.routes import url_for_node from pillar.web.nodes.routes import url_for_node
@@ -206,9 +207,24 @@ def do_yesno(value, arg=None):
return no 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: def do_json(some_object) -> str:
if isinstance(some_object, pillarsdk.Resource): if isinstance(some_object, pillarsdk.Resource):
some_object = some_object.to_dict() some_object = some_object.to_dict()
if isinstance(some_object, auth.UserClass):
some_object = user_to_dict(some_object)
return json.dumps(some_object) return json.dumps(some_object)

View File

@@ -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/<string(length=24):comment_id>', 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('/<string(length=24):node_id>/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('/<string(length=24):node_id>/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/<comment_id>/rate/<operation>", 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,
}})

View File

@@ -4,6 +4,7 @@ import logging
from datetime import datetime from datetime import datetime
import pillarsdk import pillarsdk
from pillar import shortcodes
from pillarsdk import Node from pillarsdk import Node
from pillarsdk import Project from pillarsdk import Project
from pillarsdk.exceptions import ResourceNotFound from pillarsdk.exceptions import ResourceNotFound
@@ -487,11 +488,14 @@ def preview_markdown():
current_app.csrf.protect() current_app.csrf.protect()
try: try:
content = request.form['content'] content = request.json['content']
except KeyError: except KeyError:
return jsonify({'_status': 'ERR', return jsonify({'_status': 'ERR',
'message': 'The field "content" was not specified.'}), 400 '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): 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) # Import of custom modules (using the same nodes decorator)
from .custom import comments, groups, storage, posts from .custom import groups, storage, posts

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -1,4 +1,5 @@
import { thenLoadImage, prettyDate } from '../utils'; import { prettyDate } from '../../utils/prettydate';
import { thenLoadImage } from '../utils';
import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface' import { ComponentCreatorInterface } from '../component/ComponentCreatorInterface'
export class NodesBase extends ComponentCreatorInterface { export class NodesBase extends ComponentCreatorInterface {

View File

@@ -21,102 +21,4 @@ function thenLoadVideoProgress(nodeId) {
return $.get('/api/users/video/' + nodeId + '/progress') return $.get('/api/users/video/' + nodeId + '/progress')
} }
function prettyDate(time, detail=false) { export { thenLoadImage, thenLoadVideoProgress };
/**
* 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 };

View File

@@ -1,4 +1,4 @@
import { prettyDate } from '../utils' import { prettyDate } from '../init'
describe('prettydate', () => { describe('prettydate', () => {
beforeEach(() => { beforeEach(() => {
@@ -28,7 +28,7 @@ describe('prettydate', () => {
expect(pd({minutes: -5, detailed: true})).toBe('5m ago') expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46') 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') 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: -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, detailed: true})).toBe('8 Oct at 10:46')
expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46') expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')

View File

@@ -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 }

View File

@@ -1 +1,35 @@
export { transformPlaceholder } from './placeholder' 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);
}
}

View File

@@ -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;
}

View File

@@ -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 = `
<div class="attachment"
:class="{error: !isSlugOk}"
>
<div class="thumbnail-container"
@click="$emit('insert', oid)"
title="Click to add to comment"
>
<i :class="thumbnailBackup"
v-show="!thumbnail"
/>
<img class="preview-thumbnail"
v-if="!!thumbnail"
:src="thumbnail"
width=50
height=50
/>
</div>
<input class="form-control"
title="Slug"
v-model="newSlug"
/>
<div class="actions">
<div class="action delete"
@click="$emit('delete', oid)"
>
<i class="pi-trash"/>
Delete
</div>
</div>
</div>
`;
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}`
}
}
});

View File

@@ -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 = `
<div class="comment-branch">
<div class="comment-container"
:class="{'is-first': !isReply, 'is-reply': isReply, 'comment-linked': isLinked}"
:id="comment.id">
<div class="comment-avatar">
<user-avatar
:user="comment.user"
/>
<div class="user-badges"
v-html="comment.user.badges_html">
</div>
</div>
<div class="comment-content">
<div class="comment-body"
v-if="!isUpdating"
>
<p class="comment-author">
{{ comment.user.full_name }}
</p>
<span class="comment-msg">
<p v-html="comment.msg_html"/>
</span>
</div>
<comment-editor
v-if="isUpdating"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
<div class="comment-meta">
<comment-rating
:comment="comment"
@unit-of-work="childUnitOfWork"
/>
<div class="comment-action">
<span class="action" title="Reply to this comment"
v-if="canReply"
@click="showReplyEditor"
>
Reply
</span>
<span class="action" title="Edit comment"
v-if="canUpdate"
@click="showUpdateEditor"
>
Edit
</span>
<span class="action" title="Cancel changes"
v-if="canCancel"
@click="cancleEdit"
>
<i class="pi-cancel"></i>Cancel
</span>
</div>
<pretty-created
:created="comment.created"
:updated="comment.updated"
/>
</div>
</div>
</div>
<div class="comment-reply-container is-reply"
v-if="isReplying"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="isReplying"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
</div>
<div class="comments-list">
<comment
v-for="c in comment.replies"
@unit-of-work="childUnitOfWork"
isReply=true
:readOnly="readOnly"
:comment="c"
:user="user"
:key="c.id"/>
</div>
</div>
`;
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;
},
}
});

View File

@@ -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 =`
<div class="comment-reply-form"
:class="dropTargetClasses"
>
<div class="attachments">
<comment-attachment-editor
v-for="a in attachments"
@delete="attachmentDelete"
@insert="insertAttachment"
@rename="attachmentRename"
@validation="attachmentValidation"
@unit-of-work="childUnitOfWork"
:slug="a.slug"
:allSlugs="allSlugs"
:oid="a.oid"
:key="a.oid"
/>
<upload-progress
v-if="uploads.nbrOfActive > 0"
:label="uploadProgressLabel"
:progress="uploadProgressPercent"
/>
</div>
<div class="comment-reply-field"
:class="{filled: isMsgLongEnough}"
>
<textarea
ref="inputField"
@keyup="keyUp"
v-model="msg"
id="comment_field"
placeholder="Join the conversation...">
</textarea>
<div class="comment-reply-meta">
<button class="comment-action-submit"
:class="{disabled: !canSubmit}"
@click="submit"
type="button"
title="Post Comment (Ctrl+Enter)">
<span>
<i :class="submitButtonIcon"/>{{ submitButtonText }}
</span>
<span class="hotkey">Ctrl + Enter</span>
</button>
</div>
</div>
<markdown-preview
v-show="msg.length > 0"
:markdown="msg"
:attachments="attachmentsAsObject"
/>
</div>
`;
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`;
}
}
});

View File

@@ -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 = `
<section class="comments-tree">
<div class="comment-reply-container"
v-if="canReply"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="canReply"
mode="reply"
@unit-of-work="childUnitOfWork"
:projectId="projectId"
:parentId="parentId"
:user="user"
/>
</div>
<comments-locked
v-if="readOnly||!isLoggedIn"
:user="user"
/>
<div class="comments-list-title">{{ numberOfCommentsStr }}</div>
<div class="comments-list">
<comment
v-for="c in comments"
@unit-of-work="childUnitOfWork"
:readOnly=readOnly||!isLoggedIn
:comment="c"
:user="user"
:key="c.id"/>
</div>
<generic-placeholder
v-show="showLoadingPlaceholder"
label="Loading Comments..."
/>
</section>
`;
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;
}
}
}
},
});

View File

@@ -0,0 +1,53 @@
const TEMPLATE = `
<div class="comments-locked">
<div
v-if="msgToShow === 'PROJECT_MEMBERS_ONLY'"
>
<i class="pi-lock"/>
Only project members can comment.
</div>
<div
v-if="msgToShow === 'RENEW'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="/renew" target="_blank"> Renew your subscription </a>
to comment.
</div>
<div
v-if="msgToShow === 'JOIN'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="https://store.blender.org/product/membership/" target="_blank"> Subscribe to Blender Cloud </a>
to comment.
</div>
<div
v-if="msgToShow === 'LOGIN'"
>
<a href="/login"> Log in to comment</a>
</div>
</div>
`;
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';
}
},
});

View File

@@ -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();

View File

@@ -0,0 +1,52 @@
import { EventBus, Events } from './EventBus'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { thenVoteComment } from '../../api/comments'
const TEMPLATE = `
<div class="comment-rating"
:class="{rated: hasRating, positive: isPositive }"
>
<div class="comment-rating-value" title="Number of likes">{{ rating }}</div>
<div class="comment-action-rating up" title="Like comment"
v-if="canVote"
@click="upVote"
/>
</div>
`;
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')})
);
}
}
});

View File

@@ -0,0 +1,23 @@
const TEMPLATE = `
<div class="upload-progress">
<label>
{{ label }}
</label>
<progress class="progress-uploading"
max="100"
:value="progress"
>
</progress>
</div>
`;
Vue.component('upload-progress', {
template: TEMPLATE,
props: {
label: String,
progress: {
type: Number,
default: 0
}
},
});

View File

@@ -0,0 +1 @@
import './comments/CommentTree'

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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:
* <myChild
* @unit-of-work="childUnitOfWork"
* />
*
* Use the information to enable class:
* <div :class="{disabled: 'isBusyWorking'}">
*/
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 }

View File

@@ -0,0 +1,12 @@
const TEMPLATE = `
<div class="user-avatar">
<img
:src="user.gravatar"
:alt="user.full_name">
</div>
`;
Vue.component('user-avatar', {
template: TEMPLATE,
props: {user: Object},
});

View File

@@ -0,0 +1,13 @@
const TEMPLATE =
`<div class="generic-placeholder" :title="label">
<i class="pi-spin spin"/>
{{ label }}
</div>
`;
Vue.component('generic-placeholder', {
template: TEMPLATE,
props: {
label: String,
},
});

View File

@@ -0,0 +1,56 @@
import { debounced } from '../../utils/init'
import { thenMarkdownToHtml } from '../../api/markdown'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
const TEMPLATE = `
<div class="markdown-preview">
<div class="markdown-preview-md"
v-html="asHtml"/>
<div class="markdown-preview-info">
<a
title="Handy guide of Markdown syntax"
target="_blank"
href="http://commonmark.org/help/">
<span>markdown cheatsheet</span>
</a>
</div>
</div>
`;
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');
})
);
}
}
});

View File

@@ -0,0 +1,33 @@
import { prettyDate } from '../../utils/init'
const TEMPLATE =
`<div class="pretty-created" :title="'Posted ' + created">
{{ prettyCreated }}
<span
v-if="isEdited"
:title="'Updated ' + prettyUpdated"
>*</span>
</div>
`;
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)
}
}
});

View File

@@ -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('<a id="comments-reload"><i class="pi-refresh"></i> Reload comments</a>');
});
}
/**
* Shows an error in the "Post Comment" button.
*/
function show_comment_button_error(msg) {
var $button = $('.comment-action-submit');
var $textarea = $('#comment_field');
$button.addClass('error');
$textarea.addClass('error');
$button.html(msg);
setTimeout(function(){
$button.html('<span><i class="pi-paper-plane"></i> Send</span>');
$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('<i class="pi-check"></i> Save Changes');
$button.removeClass('button-field-error');
$textarea.removeClass('field-error');
}, 2500);
}
/**
* Switches the comment to either 'edit' or 'view' mode.
*/
function comment_mode(clicked_item, mode)
{
var $container = $(clicked_item).closest('.comment-container');
var comment_id = $container.data('node-id');
var $edit_buttons = $container.find('.comment-action-edit');
if (mode == 'edit') {
$edit_buttons.find('.edit_mode').hide();
$edit_buttons.find('.edit_cancel').show();
$edit_buttons.find('.edit_save').show();
} else {
$edit_buttons.find('.edit_mode').show();
$edit_buttons.find('.edit_cancel').hide();
$edit_buttons.find('.edit_save').hide();
$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('<span><i class="pi-spin spin"></i> Sending...</span>');
})
.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('<i class="pi-paper-plane"></i> Send');
$('#comment_field').val('');
$('body').trigger('pillar:comment-posted', [comment_node_id]);
toastr.success('Comment posted!');
});
}

View File

@@ -1,410 +1,360 @@
$comments-width-max: 710px $comments-width-max: 710px
.comments-container .comments-tree
max-width: $comments-width-max max-width: $comments-width-max
position: relative position: relative
width: 100% width: 100%
#comments-reload .comments-list-title
text-align: center padding: 15px 0 10px 0
cursor: pointer 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 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 &:before
&-loading bottom: 25px
+spin color: $color-info
color: $color-background content: 'Linked Comment'
font-size: 2em font-size: .8em
margin-bottom: 10px position: absolute
position: relative right: 20px
text-align: center text-transform: uppercase
top: 25px
&-title p.comment-author
padding: 15px 0 10px 0 color: $color-text-dark
font-size: 1.1em display: inline-block
font-weight: 300 float: left
color: rgba($color-text, .5) font-weight: bold
margin-right: 8px
white-space: nowrap
/* Each comment on the list*/ &.op
.comment-container, color: $color-primary-dark
.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
&.comment-linked .pretty-created
background-color: $color-background-light !important padding-left: 10px
box-shadow: inset 3px 0 0 $color-info margin-left: 10px
padding-right: 20px color: $color-text-dark-hint
&:before &:before
bottom: 25px content: '·'
color: $color-info position: relative
content: 'Linked Comment' left: -10px
font-size: .8em font-weight: 600
position: absolute
right: 20px
text-transform: uppercase
&.is-replying /* The actual comment body. */
margin-bottom: 15px !important /* 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 border: thin solid transparent
padding-right: 5px color: darken($color-text, 10%)
padding-left: 5px font:
size: 1em
weight: normal
margin: 0
transition: background-color 200ms ease-in-out, margin 200ms ease-in-out
.comment-avatar +media-xs
padding-right: 10px padding-left: 0
font-size: $font-size
img p
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
+media-xs +media-xs
padding: padding:
left: 0 left: 0
right: 0 right: 0
align-items: center line-height: 1.4em
color: $color-text-dark-secondary margin-top: 5px
display: flex
font-size: .9em
/* Small container for rating buttons and value */ &:last-child
.comment-rating margin-bottom: 10px
display: flex
align-items: center
&.rated &.comment-author
color: $color-text-dark-secondary margin-bottom: 0
.down
color: $color-downvote
&.rated.positive img.emoji
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
display: inline-block display: inline-block
font: padding-top: inherit
size: .7em padding-bottom: inherit
weight: 400
margin: 0 5px 0 10px
padding: 1px 4px
text-transform: uppercase
&.badge-team .comment-content
border-color: $color-info display: flex
color: $color-info flex-direction: column
&.badge-op padding-bottom: 0
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
width: 100% width: 100%
&:focus &.is-reply
box-shadow: inset 2px 0 0 0 $color-success, inset 0 2px 0 0 $color-success, inset 0 -2px 0 0 $color-success padding:
border: none left: 20px
color: $color-text-dark top: 5px
outline: none margin-left: 35px
box-shadow: inset 3px 0 0 $color-background
&+.comment-reply-meta button.comment-action-submit +media-xs
box-shadow: inset -2px 0 0 0 $color-success, inset 0 2px 0 0 $color-success, inset 0 -2px 0 0 $color-success 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 &.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 .comments-locked
box-shadow: inset -2px 0 0 0 $color-danger, inset 0 2px 0 0 $color-danger, inset 0 -2px 0 0 $color-danger 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 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 &: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 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 &:hover
background-color: $color-success 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 color: white
border-bottom-right-radius: 0
span.hotkey span.hotkey
display: block color: white
display: none
font-weight: normal
font-size: .9em
&.sign-in .markdown-preview
display: block
padding: 10px
color: $color-text-dark-primary
cursor: default
&-preview
background-color: $color-background-light background-color: $color-background-light
border-bottom-left-radius: 3px border-bottom-left-radius: 3px
border-bottom-right-radius: 3px border-bottom-right-radius: 3px
box-shadow: 1px 2px 2px rgba($color-background-dark, .5) box-shadow: 1px 2px 2px rgba($color-background-dark, .5)
display: none // flex when comment-reply-field has .filled class display: flex
position: relative position: relative
transition: all 150ms ease-in-out transition: all 150ms ease-in-out
@@ -416,6 +366,15 @@ $comments-width-max: 710px
flex: 1 flex: 1
padding: 5px 10px 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 &:empty
color: transparent color: transparent
margin: 0 auto margin: 0 auto
@@ -426,93 +385,43 @@ $comments-width-max: 710px
content: '' content: ''
color: transparent color: transparent
&-info .user-badges ul.blender-id-badges
background-color: $color-background-dark list-style: none
display: flex padding: 0
flex-direction: column margin: 4px 0 0 0
font-size: .8em
padding-bottom: 10px
text-align: center
width: 100px
.comment-action-cancel li
cursor: pointer margin: 2px 0 !important
padding: 10px
text-decoration: underline
&:hover li, li a, li img
color: $color-primary padding: 0 !important
li
display: inline
img
width: 16px
height: 16px
&-meta .user-avatar
display: flex img
align-items: center border-radius: 50%
border-bottom-right-radius: 3px width: 2em
border-top-right-radius: 3px height: 2em
transition: background-color 150ms ease-in-out, color 150ms ease-in-out box-shadow: 0 0 0 0.2em $color-background-light
width: 100px
// The actual button for submitting the comment. .drag-hover
button.comment-action-submit &:before
align-items: center background-color: $color-success
background: transparent content: " "
border: none display: block
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% height: 100%
position: relative position: absolute
transition: all 200ms ease-in-out top: 0
white-space: nowrap left: 0
width: 100% width: 100%
z-index: $z-index-base + 1
opacity: 0.2
border-radius: 1em
&:hover &.unsupported-drop
background: rgba($color-success, .1) &:before
&:focus
background: lighten($color-success, 10%)
color: $white
&.submitting
color: $color-info
&.error
background-color: $color-danger 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

View File

@@ -415,7 +415,8 @@
ul li img ul li img
@extend .d-block @extend .d-block
@extend .mx-auto @extend .mx-auto
@extend .my-3 margin-top: 1rem
margin-bottom: 1rem
max-width: 100% max-width: 100%
&.emoji &.emoji

View File

@@ -30,6 +30,8 @@ html(lang="en")
| {% endblock %} | {% endblock %}
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}") 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/jquery.typeahead-0.11.1.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}") script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
| {% if current_user.is_authenticated %} | {% if current_user.is_authenticated %}

View File

@@ -56,8 +56,7 @@ script(type="text/javascript").
} }
if (ProjectUtils.nodeType() == 'asset' || ProjectUtils.nodeType() == 'post') { if (ProjectUtils.nodeType() == 'asset' || ProjectUtils.nodeType() == 'post') {
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node._id) }}"; new Vue({el:'#comments-embed'});
loadComments(commentsUrl);
} }
{% if node.has_method('PUT') %} {% if node.has_method('PUT') %}

View File

@@ -42,7 +42,9 @@
a(href="{{ node.short_link }}") {{ node.short_link }} a(href="{{ node.short_link }}") {{ node.short_link }}
| {% endif %} | {% endif %}
#comments-embed comments-tree#comments-embed(
parent-id="{{ node._id }}"
)
include ../_scripts include ../_scripts

View File

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

View File

@@ -1 +0,0 @@
| {% extends "nodes/custom/comment/list_embed_base.html" %}

View File

@@ -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($('<textarea>').text(comment_raw));
comment_content
.addClass('editing')
.find('textarea')
.autoResize()
.focus()
.trigger('keyup');
})
.fail(function(xhr) {
if (console) console.log('Error fetching comment: ', xhr);
toastr.error('Error ' + xhr.status + ' entering edit mode.');
});
});
// Abort, abort
$(document)
.off('click','body .comment-action-edit span.edit_cancel')
.on( 'click','body .comment-action-edit span.edit_cancel',function(e){
commentEditCancel(this, true);
});
// Save edited comment
$(document)
.off('click','body .comment-action-edit span.edit_save')
.on( 'click','body .comment-action-edit span.edit_save',function(e){
var $button = $(this);
var $container = $button.closest('.comment-container');
save_comment(false, $container)
.progress(function() {
$button
.addClass('submitting')
.html('<i class="pi-spin spin"></i> Posting...');
})
.fail(function(xhr) {
if (typeof xhr === 'string') {
show_comment_edit_button_error($button, xhr);
} else {
// If it's not a string, we assume it's a jQuery XHR object.
if (console) console.log('Error saving comment:', xhr.responseText);
show_comment_edit_button_error($button, "Houston! Try again?");
}
})
.done(function(comment_id, comment_html) {
commentEditCancel($button, false)
$container.find('.comment-body span')
.html(comment_html)
.removeClass('editing')
.flashOnce();
$button
.html('<i class="pi-check"></i> save changes')
.removeClass('saving');
});
});
{% endif %}
| {% endblock %}

View File

@@ -122,9 +122,9 @@ section.node-details-meta.pl-4.pr-2.py-2.border-bottom
.row .row
| {% block node_comments %} | {% block node_comments %}
.col-md-8.col-sm-12 .col-md-8.col-sm-12
#comments-embed.p-2 comments-tree#comments-embed(
.comments-list-loading parent-id="{{ node._id }}"
i.pi-spin )
| {% endblock node_comments %} | {% endblock node_comments %}
| {# Check if tags is defined and there is _actually_ a tag at least #} | {# Check if tags is defined and there is _actually_ a tag at least #}

View File

@@ -35,12 +35,11 @@ class CommentTest(AbstractPillarTest):
def test_write_comment(self): def test_write_comment(self):
with self.login_as(self.user_uid): with self.login_as(self.user_uid):
comment_url = flask.url_for('nodes.comments_create') comment_url = flask.url_for('nodes_api.post_node_comment', node_path=str(self.node_id))
self.post( self.post(
comment_url, comment_url,
data={ json={
'content': 'je möder lives at [home](https://cloud.blender.org/)', 'msg': 'je möder lives at [home](https://cloud.blender.org/)',
'parent_id': str(self.node_id),
}, },
expected_status=201, expected_status=201,
) )
@@ -51,14 +50,16 @@ class CommentEditTest(AbstractPillarTest):
super().setUp(**kwargs) super().setUp(**kwargs)
self.pid, self.project = self.ensure_project_exists() self.pid, self.project = self.ensure_project_exists()
self.uid = self.create_user(groups=[ctd.EXAMPLE_ADMIN_GROUP_ID]) self.owner_uid = self.create_user(24 * 'a',
self.create_valid_auth_token(self.uid, 'token') groups=[ctd.EXAMPLE_ADMIN_GROUP_ID],
token='admin-token')
# Create a node people can comment on.
self.node_id = self.create_node({ self.node_id = self.create_node({
'_id': ObjectId('572761099837730efe8e120d'), '_id': ObjectId('572761099837730efe8e120d'),
'description': 'This is an asset without file', 'description': 'This is an asset without file',
'node_type': 'asset', 'node_type': 'asset',
'user': self.uid, 'user': self.owner_uid,
'properties': { 'properties': {
'status': 'published', 'status': 'published',
'content_type': 'image', 'content_type': 'image',
@@ -67,31 +68,35 @@ class CommentEditTest(AbstractPillarTest):
'project': self.pid, 'project': self.pid,
}) })
self.user_uid = self.create_user(24 * 'b', groups=[ctd.EXAMPLE_ADMIN_GROUP_ID],
token='user-token')
def test_edit_comment(self): def test_edit_comment(self):
from pillar import auth
from pillar.web.nodes.custom import comments
# Create the comment # Create the comment
with self.app.test_request_context(method='POST', data={ with self.login_as(self.user_uid):
'content': 'My first comment', comment_url = flask.url_for('nodes_api.post_node_comment', node_path=str(self.node_id))
'parent_id': str(self.node_id), resp = self.post(
}): comment_url,
auth.login_user('token', load_from_db=True) json={
resp, status = comments.comments_create() 'msg': 'je möder lives at [home](https://cloud.blender.org/)',
},
expected_status=201,
)
self.assertEqual(201, status) payload = json.loads(resp.data)
payload = json.loads(resp.data) comment_id = payload['id']
comment_id = payload['node_id']
# Edit the comment comment_url = flask.url_for('nodes_api.patch_node_comment', node_path=str(self.node_id), comment_path=comment_id)
with self.app.test_request_context(method='POST', data={ # Edit the comment
'content': 'Edited comment', resp = self.patch(
}): comment_url,
auth.login_user('token', load_from_db=True) json={
resp = comments.comment_edit(comment_id) 'msg': 'Edited comment',
},
expected_status=200,
)
self.assertEqual(200, resp.status_code) self.assertEqual(200, resp.status_code)
payload = json.loads(resp.data) payload = json.loads(resp.data)
self.assertEqual('success', payload['status']) self.assertEqual('Edited comment', payload['msg_markdown'])
self.assertEqual('Edited comment', payload['data']['content']) self.assertEqual('<p>Edited comment</p>\n', payload['msg_html'])
self.assertEqual('<p>Edited comment</p>\n', payload['data']['content_html'])