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:
@@ -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',
|
||||||
|
@@ -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"
|
||||||
|
@@ -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'],
|
||||||
|
@@ -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=''):
|
||||||
|
290
pillar/api/nodes/comments.py
Normal file
290
pillar/api/nodes/comments.py
Normal 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
|
@@ -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,12 +143,12 @@ 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:
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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,
|
|
||||||
}})
|
|
@@ -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
|
||||||
|
46
src/scripts/js/es6/common/api/comments.js
Normal file
46
src/scripts/js/es6/common/api/comments.js
Normal 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 }
|
54
src/scripts/js/es6/common/api/files.js
Normal file
54
src/scripts/js/es6/common/api/files.js
Normal 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 }
|
17
src/scripts/js/es6/common/api/markdown.js
Normal file
17
src/scripts/js/es6/common/api/markdown.js
Normal 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 }
|
@@ -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 {
|
||||||
|
@@ -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 };
|
|
@@ -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')
|
34
src/scripts/js/es6/common/utils/currentuser.js
Normal file
34
src/scripts/js/es6/common/utils/currentuser.js
Normal 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 }
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
97
src/scripts/js/es6/common/utils/prettydate.js
Normal file
97
src/scripts/js/es6/common/utils/prettydate.js
Normal 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;
|
||||||
|
}
|
@@ -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}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
168
src/scripts/js/es6/common/vuecomponents/comments/Comment.js
Normal file
168
src/scripts/js/es6/common/vuecomponents/comments/Comment.js
Normal 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;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
@@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
157
src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js
Normal file
157
src/scripts/js/es6/common/vuecomponents/comments/CommentTree.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
@@ -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';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
@@ -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();
|
52
src/scripts/js/es6/common/vuecomponents/comments/Rating.js
Normal file
52
src/scripts/js/es6/common/vuecomponents/comments/Rating.js
Normal 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')})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@@ -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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
1
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
1
src/scripts/js/es6/common/vuecomponents/init.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import './comments/CommentTree'
|
86
src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js
Normal file
86
src/scripts/js/es6/common/vuecomponents/mixins/Droptarget.js
Normal 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 }
|
24
src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js
Normal file
24
src/scripts/js/es6/common/vuecomponents/mixins/Linkable.js
Normal 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 }
|
@@ -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 }
|
12
src/scripts/js/es6/common/vuecomponents/user/Avatar.js
Normal file
12
src/scripts/js/es6/common/vuecomponents/user/Avatar.js
Normal 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},
|
||||||
|
});
|
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
@@ -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');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
@@ -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!');
|
|
||||||
});
|
|
||||||
}
|
|
@@ -1,27 +1,11 @@
|
|||||||
$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
|
|
||||||
cursor: pointer
|
|
||||||
padding: 15px 0
|
|
||||||
display: block
|
|
||||||
|
|
||||||
.comments-list
|
|
||||||
&-loading
|
|
||||||
+spin
|
|
||||||
color: $color-background
|
|
||||||
font-size: 2em
|
|
||||||
margin-bottom: 10px
|
|
||||||
position: relative
|
|
||||||
text-align: center
|
|
||||||
top: 25px
|
|
||||||
|
|
||||||
&-title
|
|
||||||
padding: 15px 0 10px 0
|
padding: 15px 0 10px 0
|
||||||
font-size: 1.1em
|
font-size: 1.1em
|
||||||
font-weight: 300
|
font-weight: 300
|
||||||
@@ -49,22 +33,6 @@ $comments-width-max: 710px
|
|||||||
right: 20px
|
right: 20px
|
||||||
text-transform: uppercase
|
text-transform: uppercase
|
||||||
|
|
||||||
&.is-replying
|
|
||||||
margin-bottom: 15px !important
|
|
||||||
|
|
||||||
.comment-avatar
|
|
||||||
padding-right: 5px
|
|
||||||
padding-left: 5px
|
|
||||||
|
|
||||||
.comment-avatar
|
|
||||||
padding-right: 10px
|
|
||||||
|
|
||||||
img
|
|
||||||
border-radius: 50%
|
|
||||||
height: 24px
|
|
||||||
margin-top: 5px
|
|
||||||
width: 24px
|
|
||||||
|
|
||||||
p.comment-author
|
p.comment-author
|
||||||
color: $color-text-dark
|
color: $color-text-dark
|
||||||
display: inline-block
|
display: inline-block
|
||||||
@@ -76,7 +44,7 @@ $comments-width-max: 710px
|
|||||||
&.op
|
&.op
|
||||||
color: $color-primary-dark
|
color: $color-primary-dark
|
||||||
|
|
||||||
.comment-time
|
.pretty-created
|
||||||
padding-left: 10px
|
padding-left: 10px
|
||||||
margin-left: 10px
|
margin-left: 10px
|
||||||
color: $color-text-dark-hint
|
color: $color-text-dark-hint
|
||||||
@@ -133,29 +101,28 @@ $comments-width-max: 710px
|
|||||||
padding-top: inherit
|
padding-top: inherit
|
||||||
padding-bottom: 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
|
.comment-content
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
padding-bottom: 0
|
padding-bottom: 0
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
|
&.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-first
|
||||||
|
border-top: 1px solid $color-background
|
||||||
|
|
||||||
/* Rating, and actions such as reply */
|
/* Rating, and actions such as reply */
|
||||||
.comment-meta
|
.comment-meta
|
||||||
+media-xs
|
+media-xs
|
||||||
@@ -184,7 +151,7 @@ $comments-width-max: 710px
|
|||||||
color: $color-text-dark-secondary
|
color: $color-text-dark-secondary
|
||||||
|
|
||||||
.comment-action-rating.up:before
|
.comment-action-rating.up:before
|
||||||
content: '\e83f'
|
content: '\e83f' // Heart filled
|
||||||
|
|
||||||
|
|
||||||
.comment-rating-value
|
.comment-rating-value
|
||||||
@@ -202,28 +169,22 @@ $comments-width-max: 710px
|
|||||||
&:hover
|
&:hover
|
||||||
color: $color-upvote
|
color: $color-upvote
|
||||||
&:before
|
&:before
|
||||||
content: '\e83e'
|
content: '\e83e' // Heart outlined
|
||||||
top: 2px
|
top: 2px
|
||||||
position: relative
|
position: relative
|
||||||
|
|
||||||
.comment-action-rating.down
|
.comment-action
|
||||||
&:hover
|
|
||||||
color: $color-downvote
|
|
||||||
&:before
|
|
||||||
content: '\e838'
|
|
||||||
|
|
||||||
/* Reply button */
|
|
||||||
.comment-action-reply
|
|
||||||
color: $color-primary
|
color: $color-primary
|
||||||
|
|
||||||
&:hover
|
|
||||||
text-decoration: underline
|
|
||||||
|
|
||||||
.comment-action-reply,
|
|
||||||
.comment-action-edit
|
|
||||||
padding-left: 10px
|
padding-left: 10px
|
||||||
margin-left: 10px
|
margin-left: 10px
|
||||||
|
|
||||||
|
.action
|
||||||
|
cursor: pointer
|
||||||
|
margin-left: 15px
|
||||||
|
&:hover
|
||||||
|
color: $color-primary
|
||||||
|
text-decoration: underline
|
||||||
|
|
||||||
&:before
|
&:before
|
||||||
color: $color-text-dark-secondary
|
color: $color-text-dark-secondary
|
||||||
content: '·'
|
content: '·'
|
||||||
@@ -232,97 +193,39 @@ $comments-width-max: 710px
|
|||||||
position: relative
|
position: relative
|
||||||
text-decoration: none
|
text-decoration: none
|
||||||
|
|
||||||
span
|
.attachments
|
||||||
cursor: pointer
|
display: flex
|
||||||
&:hover
|
flex-direction: column
|
||||||
color: $color-primary
|
.attachment
|
||||||
|
display: flex
|
||||||
|
flex-direction: row
|
||||||
|
align-items: center
|
||||||
|
|
||||||
span.edit_save,
|
.preview-thumbnail
|
||||||
color: $color-success
|
margin: 0.1em
|
||||||
display: none
|
|
||||||
&:hover
|
.actions
|
||||||
color: lighten($color-success, 10%)
|
margin-left: auto
|
||||||
|
.action
|
||||||
|
cursor: pointer
|
||||||
|
color: $color-primary
|
||||||
|
margin-left: 2em
|
||||||
|
&.delete
|
||||||
|
color: $color-danger
|
||||||
|
input
|
||||||
|
max-height: 2em
|
||||||
|
|
||||||
&.error
|
&.error
|
||||||
color: $color-danger
|
input
|
||||||
|
border-color: $color-danger
|
||||||
|
|
||||||
&.saving
|
.comments-locked
|
||||||
user-select: none
|
display: block
|
||||||
pointer-events: none
|
padding: 10px
|
||||||
|
color: $color-text-dark-primary
|
||||||
cursor: default
|
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
|
|
||||||
font:
|
|
||||||
size: .7em
|
|
||||||
weight: 400
|
|
||||||
margin: 0 5px 0 10px
|
|
||||||
padding: 1px 4px
|
|
||||||
text-transform: uppercase
|
|
||||||
|
|
||||||
&.badge-team
|
|
||||||
border-color: $color-info
|
|
||||||
color: $color-info
|
|
||||||
&.badge-op
|
|
||||||
border-color: $color-primary
|
|
||||||
color: $color-primary
|
|
||||||
&.badge-own
|
|
||||||
border-color: $color-success
|
|
||||||
color: $color-success
|
|
||||||
|
|
||||||
.comment-reply
|
.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 */
|
/* textarea field, submit button and reply details */
|
||||||
&-form
|
&-form
|
||||||
padding:
|
padding:
|
||||||
@@ -354,7 +257,8 @@ $comments-width-max: 710px
|
|||||||
margin: 0
|
margin: 0
|
||||||
min-height: 35px
|
min-height: 35px
|
||||||
padding: 10px 0 10px 10px
|
padding: 10px 0 10px 10px
|
||||||
resize: vertical
|
resize: none
|
||||||
|
overflow: hidden
|
||||||
transition: box-shadow 250ms ease-in-out
|
transition: box-shadow 250ms ease-in-out
|
||||||
width: 100%
|
width: 100%
|
||||||
|
|
||||||
@@ -380,9 +284,6 @@ $comments-width-max: 710px
|
|||||||
&:focus
|
&:focus
|
||||||
border-bottom-left-radius: 0
|
border-bottom-left-radius: 0
|
||||||
|
|
||||||
&+.comment-reply-preview
|
|
||||||
display: flex
|
|
||||||
|
|
||||||
.comment-reply-meta
|
.comment-reply-meta
|
||||||
background-color: $color-success
|
background-color: $color-success
|
||||||
|
|
||||||
@@ -393,49 +294,8 @@ $comments-width-max: 710px
|
|||||||
span.hotkey
|
span.hotkey
|
||||||
display: block
|
display: block
|
||||||
|
|
||||||
&.sign-in
|
.comment-action
|
||||||
display: block
|
.action
|
||||||
padding: 10px
|
|
||||||
color: $color-text-dark-primary
|
|
||||||
cursor: default
|
|
||||||
|
|
||||||
&-preview
|
|
||||||
background-color: $color-background-light
|
|
||||||
border-bottom-left-radius: 3px
|
|
||||||
border-bottom-right-radius: 3px
|
|
||||||
box-shadow: 1px 2px 2px rgba($color-background-dark, .5)
|
|
||||||
display: none // flex when comment-reply-field has .filled class
|
|
||||||
position: relative
|
|
||||||
transition: all 150ms ease-in-out
|
|
||||||
|
|
||||||
p
|
|
||||||
padding-left: 0
|
|
||||||
padding-right: 0
|
|
||||||
|
|
||||||
&-md
|
|
||||||
flex: 1
|
|
||||||
padding: 5px 10px
|
|
||||||
|
|
||||||
&:empty
|
|
||||||
color: transparent
|
|
||||||
margin: 0 auto
|
|
||||||
padding: 0 10px
|
|
||||||
border: none
|
|
||||||
|
|
||||||
&:before
|
|
||||||
content: ''
|
|
||||||
color: transparent
|
|
||||||
|
|
||||||
&-info
|
|
||||||
background-color: $color-background-dark
|
|
||||||
display: flex
|
|
||||||
flex-direction: column
|
|
||||||
font-size: .8em
|
|
||||||
padding-bottom: 10px
|
|
||||||
text-align: center
|
|
||||||
width: 100px
|
|
||||||
|
|
||||||
.comment-action-cancel
|
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
padding: 10px
|
padding: 10px
|
||||||
text-decoration: underline
|
text-decoration: underline
|
||||||
@@ -489,19 +349,43 @@ $comments-width-max: 710px
|
|||||||
font-weight: normal
|
font-weight: normal
|
||||||
font-size: .9em
|
font-size: .9em
|
||||||
|
|
||||||
|
.markdown-preview
|
||||||
|
background-color: $color-background-light
|
||||||
|
border-bottom-left-radius: 3px
|
||||||
|
border-bottom-right-radius: 3px
|
||||||
|
box-shadow: 1px 2px 2px rgba($color-background-dark, .5)
|
||||||
|
display: flex
|
||||||
|
position: relative
|
||||||
|
transition: all 150ms ease-in-out
|
||||||
|
|
||||||
/* Style the comment container when we're replying */
|
p
|
||||||
.comment-container + .comment-reply-container
|
padding-left: 0
|
||||||
margin-left: 30px
|
padding-right: 0
|
||||||
padding-top: 10px
|
|
||||||
|
|
||||||
.comment-reply-form
|
&-md
|
||||||
.comment-reply-meta
|
flex: 1
|
||||||
button.comment-action-cancel
|
padding: 5px 10px
|
||||||
display: inline-block
|
|
||||||
|
|
||||||
|
&-info
|
||||||
|
background-color: $color-background-dark
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
font-size: .8em
|
||||||
|
padding-bottom: 10px
|
||||||
|
text-align: center
|
||||||
|
width: 100px
|
||||||
|
|
||||||
.comment-badges ul.blender-id-badges
|
&:empty
|
||||||
|
color: transparent
|
||||||
|
margin: 0 auto
|
||||||
|
padding: 0 10px
|
||||||
|
border: none
|
||||||
|
|
||||||
|
&:before
|
||||||
|
content: ''
|
||||||
|
color: transparent
|
||||||
|
|
||||||
|
.user-badges ul.blender-id-badges
|
||||||
list-style: none
|
list-style: none
|
||||||
padding: 0
|
padding: 0
|
||||||
margin: 4px 0 0 0
|
margin: 4px 0 0 0
|
||||||
@@ -516,3 +400,28 @@ $comments-width-max: 710px
|
|||||||
img
|
img
|
||||||
width: 16px
|
width: 16px
|
||||||
height: 16px
|
height: 16px
|
||||||
|
|
||||||
|
.user-avatar
|
||||||
|
img
|
||||||
|
border-radius: 50%
|
||||||
|
width: 2em
|
||||||
|
height: 2em
|
||||||
|
box-shadow: 0 0 0 0.2em $color-background-light
|
||||||
|
|
||||||
|
.drag-hover
|
||||||
|
&:before
|
||||||
|
background-color: $color-success
|
||||||
|
content: " "
|
||||||
|
display: block
|
||||||
|
height: 100%
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
width: 100%
|
||||||
|
z-index: $z-index-base + 1
|
||||||
|
opacity: 0.2
|
||||||
|
border-radius: 1em
|
||||||
|
|
||||||
|
&.unsupported-drop
|
||||||
|
&:before
|
||||||
|
background-color: $color-danger
|
||||||
|
@@ -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
|
||||||
|
@@ -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 %}
|
||||||
|
@@ -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') %}
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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 -%}
|
|
@@ -1 +0,0 @@
|
|||||||
| {% extends "nodes/custom/comment/list_embed_base.html" %}
|
|
@@ -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 %}
|
|
@@ -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 #}
|
||||||
|
@@ -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['node_id']
|
comment_id = payload['id']
|
||||||
|
|
||||||
|
comment_url = flask.url_for('nodes_api.patch_node_comment', node_path=str(self.node_id), comment_path=comment_id)
|
||||||
# Edit the comment
|
# Edit the comment
|
||||||
with self.app.test_request_context(method='POST', data={
|
resp = self.patch(
|
||||||
'content': 'Edited comment',
|
comment_url,
|
||||||
}):
|
json={
|
||||||
auth.login_user('token', load_from_db=True)
|
'msg': 'Edited comment',
|
||||||
resp = comments.comment_edit(comment_id)
|
},
|
||||||
|
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'])
|
|
Reference in New Issue
Block a user