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

View File

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

View File

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

View File

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

View File

@ -6,14 +6,14 @@ import pymongo.errors
import werkzeug.exceptions as wz_exceptions
from flask import current_app, Blueprint, request
from pillar.api.nodes import eve_hooks
from pillar.api.nodes import eve_hooks, comments
from pillar.api.utils import str2id, jsonify
from pillar.api.utils.authorization import check_permissions, require_login
from pillar.web.utils import pretty_date
log = logging.getLogger(__name__)
blueprint = Blueprint('nodes_api', __name__)
ROLES_FOR_SHARING = {'subscriber', 'demo'}
ROLES_FOR_SHARING = ROLES_FOR_COMMENTING ={'subscriber', 'demo'}
@blueprint.route('/<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)
@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/<tag>')
def tagged(tag=''):

View File

@ -0,0 +1,290 @@
import logging
from datetime import datetime
import pymongo
import typing
import bson
import attr
import werkzeug.exceptions as wz_exceptions
import pillar
from pillar import current_app, shortcodes
from pillar.api.nodes.custom.comment import patch_comment
from pillar.api.utils import jsonify, gravatar
from pillar.auth import current_user
log = logging.getLogger(__name__)
@attr.s(auto_attribs=True)
class UserDO:
id: str
full_name: str
gravatar: str
badges_html: str
@attr.s(auto_attribs=True)
class CommentPropertiesDO:
attachments: typing.Dict
rating_positive: int = 0
rating_negative: int = 0
@attr.s(auto_attribs=True)
class CommentDO:
id: bson.ObjectId
parent: bson.ObjectId
project: bson.ObjectId
user: UserDO
msg_html: str
msg_markdown: str
properties: CommentPropertiesDO
created: datetime
updated: datetime
etag: str
replies: typing.List['CommentDO'] = []
current_user_rating: typing.Optional[bool] = None
@attr.s(auto_attribs=True)
class CommentTreeDO:
node_id: bson.ObjectId
project: bson.ObjectId
nbr_of_comments: int = 0
comments: typing.List[CommentDO] = []
def _get_markdowned_html(document: dict, field_name: str) -> str:
cache_field_name = pillar.markdown.cache_field_name(field_name)
html = document.get(cache_field_name)
if html is None:
markdown_src = document.get(field_name) or ''
html = pillar.markdown.markdown(markdown_src)
return html
def jsonify_data_object(data_object: attr):
return jsonify(
attr.asdict(data_object,
recurse=True)
)
class CommentTreeBuilder:
def __init__(self, node_id: bson.ObjectId):
self.node_id = node_id
self.nbr_of_Comments: int = 0
def build(self) -> CommentTreeDO:
enriched_comments = self.child_comments(self.node_id)
project_id = self.get_project_id()
return CommentTreeDO(
node_id=self.node_id,
project=project_id,
nbr_of_comments=self.nbr_of_Comments,
comments=enriched_comments
)
def child_comments(self, node_id: bson.ObjectId) -> typing.List[CommentDO]:
raw_comments = self.mongodb_comments(node_id)
return [self.enrich(comment) for comment in raw_comments]
def enrich(self, mongo_comment: dict) -> CommentDO:
self.nbr_of_Comments += 1
comment = to_comment_data_object(mongo_comment)
comment.replies = self.child_comments(mongo_comment['_id'])
return comment
def get_project_id(self):
nodes_coll = current_app.db('nodes')
result = nodes_coll.find_one({'_id': self.node_id})
return result['project']
@classmethod
def mongodb_comments(cls, node_id: bson.ObjectId) -> typing.Iterator:
nodes_coll = current_app.db('nodes')
return nodes_coll.aggregate([
{'$match': {'node_type': 'comment',
'_deleted': {'$ne': True},
'properties.status': 'published',
'parent': node_id}},
{'$lookup': {"from": "users",
"localField": "user",
"foreignField": "_id",
"as": "user"}},
{'$unwind': {'path': "$user"}},
{'$sort': {'properties.rating_positive': pymongo.DESCENDING,
'_created': pymongo.DESCENDING}},
])
def get_node_comments(node_id: bson.ObjectId):
comments_tree = CommentTreeBuilder(node_id).build()
return jsonify_data_object(comments_tree)
def post_node_comment(parent_id: bson.ObjectId, markdown_msg: str, attachments: dict):
parent_node = find_node_or_raise(parent_id,
'User %s tried to update comment with bad parent_id %s',
current_user.objectid,
parent_id)
is_reply = parent_node['node_type'] == 'comment'
comment = dict(
parent=parent_id,
project=parent_node['project'],
name='Comment',
user=current_user.objectid,
node_type='comment',
properties=dict(
content=markdown_msg,
status='published',
is_reply=is_reply,
confidence=0,
rating_positive=0,
rating_negative=0,
attachments=attachments,
)
)
r, _, _, status = current_app.post_internal('nodes', comment)
if status != 201:
log.warning('Unable to post comment on %s as %s: %s',
parent_id, current_user.objectid, r)
raise wz_exceptions.InternalServerError('Unable to create comment')
comment_do = get_comment(parent_id, r['_id'])
return jsonify_data_object(comment_do), 201
def find_node_or_raise(node_id, *args):
nodes_coll = current_app.db('nodes')
node_to_comment = nodes_coll.find_one({
'_id': node_id,
'_deleted': {'$ne': True},
})
if not node_to_comment:
log.warning(args)
raise wz_exceptions.UnprocessableEntity()
return node_to_comment
def patch_node_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId, markdown_msg: str, attachments: dict):
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
patch = dict(
op='edit',
content=markdown_msg,
attachments=attachments
)
json_result = patch_comment(comment_id, patch)
if json_result.json['result'] != 200:
raise wz_exceptions.InternalServerError('Failed to update comment')
comment_do = get_comment(parent_id, comment_id)
return jsonify_data_object(comment_do), 200
def find_parent_and_comment_or_raise(parent_id, comment_id):
parent = find_node_or_raise(parent_id,
'User %s tried to update comment with bad parent_id %s',
current_user.objectid,
parent_id)
comment = find_node_or_raise(comment_id,
'User %s tried to update comment with bad id %s',
current_user.objectid,
comment_id)
validate_comment_parent_relation(comment, parent)
return parent, comment
def validate_comment_parent_relation(comment, parent):
if comment['parent'] != parent['_id']:
log.warning('User %s tried to update comment with bad parent/comment pair. parent_id: %s comment_id: %s',
current_user.objectid,
parent['_id'],
comment['_id'])
raise wz_exceptions.BadRequest()
def get_comment(parent_id: bson.ObjectId, comment_id: bson.ObjectId) -> CommentDO:
nodes_coll = current_app.db('nodes')
mongo_comment = list(nodes_coll.aggregate([
{'$match': {'node_type': 'comment',
'_deleted': {'$ne': True},
'properties.status': 'published',
'parent': parent_id,
'_id': comment_id}},
{'$lookup': {"from": "users",
"localField": "user",
"foreignField": "_id",
"as": "user"}},
{'$unwind': {'path': "$user"}},
]))[0]
return to_comment_data_object(mongo_comment)
def to_comment_data_object(mongo_comment: dict) -> CommentDO:
def current_user_rating():
if current_user.is_authenticated:
for rating in mongo_comment['properties'].get('ratings', ()):
if str(rating['user']) != current_user.objectid:
continue
return rating['is_positive']
return None
user_dict = mongo_comment['user']
user = UserDO(
id=str(mongo_comment['user']['_id']),
full_name=user_dict['full_name'],
gravatar=gravatar(user_dict['email']),
badges_html=user_dict.get('badges', {}).get('html', '')
)
html = _get_markdowned_html(mongo_comment['properties'], 'content')
html = shortcodes.render_commented(html, context=mongo_comment['properties'])
return CommentDO(
id=mongo_comment['_id'],
parent=mongo_comment['parent'],
project=mongo_comment['project'],
user=user,
msg_html=html,
msg_markdown=mongo_comment['properties']['content'],
current_user_rating=current_user_rating(),
created=mongo_comment['_created'],
updated=mongo_comment['_updated'],
etag=mongo_comment['_etag'],
properties=CommentPropertiesDO(
attachments=mongo_comment['properties'].get('attachments', {}),
rating_positive=mongo_comment['properties']['rating_positive'],
rating_negative=mongo_comment['properties']['rating_negative']
)
)
def post_node_comment_vote(parent_id: bson.ObjectId, comment_id: bson.ObjectId, vote: int):
normalized_vote = min(max(vote, -1), 1)
_, _ = find_parent_and_comment_or_raise(parent_id, comment_id)
actions = {
1: 'upvote',
0: 'revoke',
-1: 'downvote',
}
patch = dict(
op=actions[normalized_vote]
)
json_result = patch_comment(comment_id, patch)
if json_result.json['_status'] != 'OK':
raise wz_exceptions.InternalServerError('Failed to vote on comment')
comment_do = get_comment(parent_id, comment_id)
return jsonify_data_object(comment_do), 200

View File

@ -5,7 +5,7 @@ import logging
from flask import current_app
import werkzeug.exceptions as wz_exceptions
from pillar.api.utils import authorization, authentication, jsonify
from pillar.api.utils import authorization, authentication, jsonify, remove_private_keys
from . import register_patch_handler
@ -135,10 +135,7 @@ def edit_comment(user_id, node_id, patch):
# we can pass this stuff to Eve's patch_internal; that way the validation &
# authorisation system has enough info to work.
nodes_coll = current_app.data.driver.db['nodes']
projection = {'user': 1,
'project': 1,
'node_type': 1}
node = nodes_coll.find_one(node_id, projection=projection)
node = nodes_coll.find_one(node_id)
if node is None:
log.warning('User %s wanted to patch non-existing node %s' % (user_id, node_id))
raise wz_exceptions.NotFound('Node %s not found' % node_id)
@ -146,12 +143,12 @@ def edit_comment(user_id, node_id, patch):
if node['user'] != user_id and not authorization.user_has_role('admin'):
raise wz_exceptions.Forbidden('You can only edit your own comments.')
# Use Eve to PATCH this node, as that also updates the etag.
r, _, _, status = current_app.patch_internal('nodes',
{'properties.content': patch['content'],
'project': node['project'],
'user': node['user'],
'node_type': node['node_type']},
node = remove_private_keys(node)
node['properties']['content'] = patch['content']
node['properties']['attachments'] = patch.get('attachments', {})
# Use Eve to PUT this node, as that also updates the etag and we want to replace attachments.
r, _, _, status = current_app.put_internal('nodes',
node,
concurrency_check=False,
_id=node_id)
if status != 200:

View File

@ -14,6 +14,7 @@ import werkzeug.exceptions as wz_exceptions
import pillarsdk
import pillar.api.utils
from pillar import auth
from pillar.api.utils import pretty_duration
from pillar.web.utils import pretty_date
from pillar.web.nodes.routes import url_for_node
@ -206,9 +207,24 @@ def do_yesno(value, arg=None):
return no
def user_to_dict(user: auth.UserClass) -> dict:
return dict(
user_id=str(user.user_id),
username=user.username,
full_name=user.full_name,
gravatar=user.gravatar,
email=user.email,
capabilities=list(user.capabilities),
badges_html=user.badges_html,
is_authenticated=user.is_authenticated
)
def do_json(some_object) -> str:
if isinstance(some_object, pillarsdk.Resource):
some_object = some_object.to_dict()
if isinstance(some_object, auth.UserClass):
some_object = user_to_dict(some_object)
return json.dumps(some_object)

View File

@ -1,241 +0,0 @@
import logging
from flask import current_app
from flask import request
from flask import jsonify
from flask import render_template
from flask_login import login_required, current_user
from pillarsdk import Node
from pillarsdk import Project
import werkzeug.exceptions as wz_exceptions
from pillar.api.utils import utcnow
from pillar.web import subquery
from pillar.web.nodes.routes import blueprint
from pillar.web.utils import gravatar
from pillar.web.utils import pretty_date
from pillar.web.utils import system_util
log = logging.getLogger(__name__)
@blueprint.route('/comments/create', methods=['POST'])
@login_required
def comments_create():
content = request.form['content']
parent_id = request.form.get('parent_id')
if not parent_id:
log.warning('User %s tried to create comment without parent_id', current_user.objectid)
raise wz_exceptions.UnprocessableEntity()
api = system_util.pillar_api()
parent_node = Node.find(parent_id, api=api)
if not parent_node:
log.warning('Unable to create comment for user %s, parent node %r not found',
current_user.objectid, parent_id)
raise wz_exceptions.UnprocessableEntity()
log.info('Creating comment for user %s on parent node %r',
current_user.objectid, parent_id)
comment_props = dict(
project=parent_node.project,
name='Comment',
user=current_user.objectid,
node_type='comment',
properties=dict(
content=content,
status='published',
confidence=0,
rating_positive=0,
rating_negative=0))
if parent_id:
comment_props['parent'] = parent_id
# Get the parent node and check if it's a comment. In which case we flag
# the current comment as a reply.
parent_node = Node.find(parent_id, api=api)
if parent_node.node_type == 'comment':
comment_props['properties']['is_reply'] = True
comment = Node(comment_props)
comment.create(api=api)
return jsonify({'node_id': comment._id}), 201
@blueprint.route('/comments/<string(length=24):comment_id>', methods=['POST'])
@login_required
def comment_edit(comment_id):
"""Allows a user to edit their comment."""
from pillar.web import jinja
api = system_util.pillar_api()
comment = Node({'_id': comment_id})
result = comment.patch({'op': 'edit', 'content': request.form['content']}, api=api)
assert result['_status'] == 'OK'
return jsonify({
'status': 'success',
'data': {
'content': result.properties.content or '',
'content_html': jinja.do_markdowned(result.properties, 'content'),
}})
def format_comment(comment, is_reply=False, is_team=False, replies=None):
"""Format a comment node into a simpler dictionary.
:param comment: the comment object
:param is_reply: True if the comment is a reply to another comment
:param is_team: True if the author belongs to the group that owns the node
:param replies: list of replies (formatted with this function)
"""
try:
is_own = (current_user.objectid == comment.user._id) \
if current_user.is_authenticated else False
except AttributeError:
current_app.bugsnag.notify(Exception(
'Missing user for embedded user ObjectId'),
meta_data={'nodes_info': {'node_id': comment['_id']}})
return
is_rated = False
is_rated_positive = None
if comment.properties.ratings:
for rating in comment.properties.ratings:
if current_user.is_authenticated and rating.user == current_user.objectid:
is_rated = True
is_rated_positive = rating.is_positive
break
return dict(_id=comment._id,
gravatar=gravatar(comment.user.email, size=32),
time_published=pretty_date(comment._created or utcnow(), detail=True),
rating=comment.properties.rating_positive - comment.properties.rating_negative,
author=comment.user.full_name,
author_username=comment.user.username,
content=comment.properties.content,
is_reply=is_reply,
is_own=is_own,
is_rated=is_rated,
is_rated_positive=is_rated_positive,
is_team=is_team,
replies=replies)
@blueprint.route('/<string(length=24):node_id>/comments')
def comments_for_node(node_id):
"""Shows the comments attached to the given node.
The URL can be overridden in order to define can_post_comments in a different way
"""
api = system_util.pillar_api()
node = Node.find(node_id, api=api)
project = Project({'_id': node.project})
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
can_comment_override = request.args.get('can_comment', 'True') == 'True'
can_post_comments = can_post_comments and can_comment_override
return render_comments_for_node(node_id, can_post_comments=can_post_comments)
def render_comments_for_node(node_id: str, *, can_post_comments: bool):
"""Render the list of comments for a node.
Comments are first sorted by rating_positive and then by creation date.
"""
api = system_util.pillar_api()
# Query for all children, i.e. comments on the node.
comments = Node.all({
'where': {'node_type': 'comment', 'parent': node_id},
'sort': [('properties.rating_positive', -1), ('_created', -1)],
}, api=api)
def enrich(some_comment):
some_comment['_user'] = subquery.get_user_info(some_comment['user'])
some_comment['_is_own'] = some_comment['user'] == current_user.objectid
some_comment['_current_user_rating'] = None # tri-state boolean
some_comment[
'_rating'] = some_comment.properties.rating_positive - some_comment.properties.rating_negative
if current_user.is_authenticated:
for rating in some_comment.properties.ratings or ():
if rating.user != current_user.objectid:
continue
some_comment['_current_user_rating'] = rating.is_positive
for comment in comments['_items']:
# Query for all grandchildren, i.e. replies to comments on the node.
comment['_replies'] = Node.all({
'where': {'node_type': 'comment', 'parent': comment['_id']},
'sort': [('properties.rating_positive', -1), ('_created', -1)],
}, api=api)
enrich(comment)
for reply in comment['_replies']['_items']:
enrich(reply)
nr_of_comments = sum(1 + comment['_replies']['_meta']['total']
for comment in comments['_items'])
return render_template('nodes/custom/comment/list_embed.html',
node_id=node_id,
comments=comments,
nr_of_comments=nr_of_comments,
show_comments=True,
can_post_comments=can_post_comments)
@blueprint.route('/<string(length=24):node_id>/commentform')
def commentform_for_node(node_id):
"""Shows only the comment for for comments attached to the given node.
i.e. does not show the comments themselves, just the form to post a new comment.
"""
api = system_util.pillar_api()
node = Node.find(node_id, api=api)
project = Project({'_id': node.project})
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
return render_template('nodes/custom/comment/list_embed.html',
node_id=node_id,
show_comments=False,
can_post_comments=can_post_comments)
@blueprint.route("/comments/<comment_id>/rate/<operation>", methods=['POST'])
@login_required
def comments_rate(comment_id, operation):
"""Comment rating function
:param comment_id: the comment id
:type comment_id: str
:param rating: the rating (is cast from 0 to False and from 1 to True)
:type rating: int
"""
if operation not in {'revoke', 'upvote', 'downvote'}:
raise wz_exceptions.BadRequest('Invalid operation')
api = system_util.pillar_api()
# PATCH the node and return the result.
comment = Node({'_id': comment_id})
result = comment.patch({'op': operation}, api=api)
assert result['_status'] == 'OK'
return jsonify({
'status': 'success',
'data': {
'op': operation,
'rating_positive': result.properties.rating_positive,
'rating_negative': result.properties.rating_negative,
}})

View File

@ -4,6 +4,7 @@ import logging
from datetime import datetime
import pillarsdk
from pillar import shortcodes
from pillarsdk import Node
from pillarsdk import Project
from pillarsdk.exceptions import ResourceNotFound
@ -487,11 +488,14 @@ def preview_markdown():
current_app.csrf.protect()
try:
content = request.form['content']
content = request.json['content']
except KeyError:
return jsonify({'_status': 'ERR',
'message': 'The field "content" was not specified.'}), 400
return jsonify(content=markdown(content))
html = markdown(content)
attachmentsdict = request.json.get('attachments', {})
html = shortcodes.render_commented(html, context={'attachments': attachmentsdict})
return jsonify(content=html)
def ensure_lists_exist_as_empty(node_doc, node_type):
@ -605,4 +609,4 @@ def url_for_node(node_id=None, node=None):
# Import of custom modules (using the same nodes decorator)
from .custom import comments, groups, storage, posts
from .custom import groups, storage, posts

View File

@ -0,0 +1,46 @@
function thenGetComments(parentId) {
return $.getJSON(`/api/nodes/${parentId}/comments`);
}
function thenCreateComment(parentId, msg, attachments) {
let data = JSON.stringify({
msg: msg,
attachments: attachments
});
return $.ajax({
url: `/api/nodes/${parentId}/comments`,
type: 'POST',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
function thenUpdateComment(parentId, commentId, msg, attachments) {
let data = JSON.stringify({
msg: msg,
attachments: attachments
});
return $.ajax({
url: `/api/nodes/${parentId}/comments/${commentId}`,
type: 'PATCH',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
function thenVoteComment(parentId, commentId, vote) {
let data = JSON.stringify({
vote: vote
});
return $.ajax({
url: `/api/nodes/${parentId}/comments/${commentId}/vote`,
type: 'POST',
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
});
}
export { thenGetComments, thenCreateComment, thenUpdateComment, thenVoteComment }

View File

@ -0,0 +1,54 @@
function thenUploadFile(projectId, file, progressCB=(total, loaded)=>{}) {
let formData = createFormData(file)
return $.ajax({
url: `/api/storage/stream/${projectId}`,
type: 'POST',
data: formData,
cache: false,
contentType: false,
processData: false,
xhr: () => {
let myxhr = $.ajaxSettings.xhr();
if (myxhr.upload) {
// For handling the progress of the upload
myxhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
progressCB(e.total, e.loaded);
}
}, false);
}
return myxhr;
}
});
}
function createFormData(file) {
let formData = new FormData();
formData.append('file', file);
return formData;
}
function thenGetFileDocument(fileId) {
return $.get(`/api/files/${fileId}`);
}
function getFileVariation(fileDoc, size = 'm') {
var show_variation = null;
if (typeof fileDoc.variations != 'undefined') {
for (var variation of fileDoc.variations) {
if (variation.size != size) continue;
show_variation = variation;
break;
}
}
if (show_variation == null) {
throw 'Image not found: ' + fileDoc._id + ' size: ' + size;
}
return show_variation;
}
export { thenUploadFile, thenGetFileDocument, getFileVariation }

View File

@ -0,0 +1,17 @@
function thenMarkdownToHtml(markdown, attachments={}) {
let data = JSON.stringify({
content: markdown,
attachments: attachments
});
return $.ajax({
url: "/nodes/preview-markdown",
type: 'POST',
headers: {"X-CSRFToken": csrf_token},
headers: {},
data: data,
dataType: 'json',
contentType: 'application/json; charset=UTF-8'
})
}
export { thenMarkdownToHtml }

View File

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

View File

@ -21,102 +21,4 @@ function thenLoadVideoProgress(nodeId) {
return $.get('/api/users/video/' + nodeId + '/progress')
}
function prettyDate(time, detail=false) {
/**
* time is anything Date can parse, and we return a
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
'just now', etc
*/
let theDate = new Date(time);
if (!time || isNaN(theDate)) {
return
}
let pretty = '';
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
}
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
}
else if (day_diff < -7){
let week_count = Math.round(-day_diff / 7);
if (week_count == 1)
pretty = "in 1 week";
else
pretty = "in " + week_count +" weeks";
}
else if (day_diff < -1)
// "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) {
if (second_diff < 0) {
let seconds = Math.abs(second_diff);
if (seconds < 10)
return 'just now';
if (seconds < 60)
return 'in ' + seconds +'s';
if (seconds < 120)
return 'in a minute';
if (seconds < 3600)
return 'in ' + Math.round(seconds / 60) + 'm';
if (seconds < 7200)
return 'in an hour';
if (seconds < 86400)
return 'in ' + Math.round(seconds / 3600) + 'h';
} else {
let seconds = second_diff;
if (seconds < 10)
return "just now";
if (seconds < 60)
return seconds + "s ago";
if (seconds < 120)
return "a minute ago";
if (seconds < 3600)
return Math.round(seconds / 60) + "m ago";
if (seconds < 7200)
return "an hour ago";
if (seconds < 86400)
return Math.round(seconds / 3600) + "h ago";
}
}
else if (day_diff == 1)
pretty = "yesterday";
else if (day_diff <= 7)
// "last Tuesday"
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff <= 22) {
let week_count = Math.round(day_diff / 7);
if (week_count == 1)
pretty = "1 week ago";
else
pretty = week_count + " weeks ago";
}
else if (theDate.getFullYear() === now.getFullYear())
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
else
// "Jul 16", 2009
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
if (detail){
// "Tuesday at 04:20"
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
return pretty + ' at ' + paddedHour + ':' + paddedMin;
}
return pretty;
}
export { thenLoadImage, thenLoadVideoProgress, prettyDate };
export { thenLoadImage, thenLoadVideoProgress };

View File

@ -1,4 +1,4 @@
import { prettyDate } from '../utils'
import { prettyDate } from '../init'
describe('prettydate', () => {
beforeEach(() => {
@ -28,7 +28,7 @@ describe('prettydate', () => {
expect(pd({minutes: -5, detailed: true})).toBe('5m ago')
expect(pd({days: -7, detailed: true})).toBe('last Tuesday at 11:46')
expect(pd({days: -8, detailed: true})).toBe('1 week ago at 11:46')
// summer time bellow
// summer time below
expect(pd({days: -14, detailed: true})).toBe('2 weeks ago at 10:46')
expect(pd({days: -31, detailed: true})).toBe('8 Oct at 10:46')
expect(pd({days: -(31 + 366), detailed: true})).toBe('8 Oct 2015 at 10:46')

View File

@ -0,0 +1,34 @@
class User{
constructor(kwargs) {
this.user_id = kwargs['user_id'] || '';
this.username = kwargs['username'] || '';
this.full_name = kwargs['full_name'] || '';
this.gravatar = kwargs['gravatar'] || '';
this.email = kwargs['email'] || '';
this.capabilities = kwargs['capabilities'] || [];
this.badges_html = kwargs['badges_html'] || '';
this.is_authenticated = kwargs['is_authenticated'] || false;
}
/**
* """Returns True iff the user has one or more of the given capabilities."""
* @param {...String} args
*/
hasCap(...args) {
for(let cap of args) {
if (this.capabilities.indexOf(cap) != -1) return true;
}
return false;
}
}
let currentUser;
function initCurrentUser(kwargs){
currentUser = new User(kwargs);
}
function getCurrentUser() {
return currentUser;
}
export { getCurrentUser, initCurrentUser }

View File

@ -1 +1,35 @@
export { transformPlaceholder } from './placeholder'
export { prettyDate } from './prettydate'
export { getCurrentUser, initCurrentUser } from './currentuser'
export function debounced(fn, delay=1000) {
let timerId;
return function (...args) {
if (timerId) {
clearTimeout(timerId);
}
timerId = setTimeout(() => {
fn(...args);
timerId = null;
}, delay);
}
}
/**
* Extracts error message from error of type String, Error or xhrError
* @param {*} err
* @returns {String}
*/
export function messageFromError(err){
if (typeof err === "string") {
// type String
return err;
} else if(typeof err.message === "string") {
// type Error
return err.message;
} else {
// type xhr probably
return xhrErrorResponseMessage(err);
}
}

View File

@ -0,0 +1,97 @@
export function prettyDate(time, detail=false) {
/**
* time is anything Date can parse, and we return a
pretty string like 'an hour ago', 'Yesterday', '3 months ago',
'just now', etc
*/
let theDate = new Date(time);
if (!time || isNaN(theDate)) {
return
}
let pretty = '';
let now = new Date(Date.now()); // Easier to mock Date.now() in tests
let second_diff = Math.round((now - theDate) / 1000);
let day_diff = Math.round(second_diff / 86400); // seconds per day (60*60*24)
if ((day_diff < 0) && (theDate.getFullYear() !== now.getFullYear())) {
// "Jul 16, 2018"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
}
else if ((day_diff < -21) && (theDate.getFullYear() == now.getFullYear())) {
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
}
else if (day_diff < -7){
let week_count = Math.round(-day_diff / 7);
if (week_count == 1)
pretty = "in 1 week";
else
pretty = "in " + week_count +" weeks";
}
else if (day_diff < -1)
// "next Tuesday"
pretty = 'next ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff === 0) {
if (second_diff < 0) {
let seconds = Math.abs(second_diff);
if (seconds < 10)
return 'just now';
if (seconds < 60)
return 'in ' + seconds +'s';
if (seconds < 120)
return 'in a minute';
if (seconds < 3600)
return 'in ' + Math.round(seconds / 60) + 'm';
if (seconds < 7200)
return 'in an hour';
if (seconds < 86400)
return 'in ' + Math.round(seconds / 3600) + 'h';
} else {
let seconds = second_diff;
if (seconds < 10)
return "just now";
if (seconds < 60)
return seconds + "s ago";
if (seconds < 120)
return "a minute ago";
if (seconds < 3600)
return Math.round(seconds / 60) + "m ago";
if (seconds < 7200)
return "an hour ago";
if (seconds < 86400)
return Math.round(seconds / 3600) + "h ago";
}
}
else if (day_diff == 1)
pretty = "yesterday";
else if (day_diff <= 7)
// "last Tuesday"
pretty = 'last ' + theDate.toLocaleDateString('en-NL',{weekday: 'long'});
else if (day_diff <= 22) {
let week_count = Math.round(day_diff / 7);
if (week_count == 1)
pretty = "1 week ago";
else
pretty = week_count + " weeks ago";
}
else if (theDate.getFullYear() === now.getFullYear())
// "Jul 16"
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short'});
else
// "Jul 16", 2009
pretty = theDate.toLocaleDateString('en-NL',{day: 'numeric', month: 'short', year: 'numeric'});
if (detail){
// "Tuesday at 04:20"
let paddedHour = ('00' + theDate.getUTCHours()).substr(-2);
let paddedMin = ('00' + theDate.getUTCMinutes()).substr(-2);
return pretty + ' at ' + paddedHour + ':' + paddedMin;
}
return pretty;
}

View File

@ -0,0 +1,120 @@
import { thenGetFileDocument, getFileVariation } from '../../api/files'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
const VALID_NAME_REGEXP = /[a-zA-Z0-9_\-]+/g;
const NON_VALID_NAME_REGEXP = /[^a-zA-Z0-9_\-]+/g;
const TEMPLATE = `
<div class="attachment"
:class="{error: !isSlugOk}"
>
<div class="thumbnail-container"
@click="$emit('insert', oid)"
title="Click to add to comment"
>
<i :class="thumbnailBackup"
v-show="!thumbnail"
/>
<img class="preview-thumbnail"
v-if="!!thumbnail"
:src="thumbnail"
width=50
height=50
/>
</div>
<input class="form-control"
title="Slug"
v-model="newSlug"
/>
<div class="actions">
<div class="action delete"
@click="$emit('delete', oid)"
>
<i class="pi-trash"/>
Delete
</div>
</div>
</div>
`;
Vue.component('comment-attachment-editor', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
slug: String,
allSlugs: Array,
oid: String
},
data() {
return {
newSlug: this.slug,
thumbnail: '',
thumbnailBackup: 'pi-spin spin',
}
},
computed: {
isValidAttachmentName() {
let regexpMatch = this.slug.match(VALID_NAME_REGEXP);
return !!regexpMatch && regexpMatch.length === 1 && regexpMatch[0] === this.slug;
},
isUnique() {
let countOccurrences = 0;
for (let s of this.allSlugs) {
// Don't worry about unicode. isValidAttachmentName denies those anyway
if (s.toUpperCase() === this.slug.toUpperCase()) {
countOccurrences++;
}
}
return countOccurrences === 1;
},
isSlugOk() {
return this.isValidAttachmentName && this.isUnique;
}
},
watch: {
newSlug(newValue, oldValue) {
this.$emit('rename', newValue, this.oid);
},
isSlugOk(newValue, oldValue) {
this.$emit('validation', this.oid, newValue);
}
},
created() {
this.newSlug = this.makeSafeAttachmentString(this.slug);
this.$emit('validation', this.oid, this.isSlugOk);
this.unitOfWork(
thenGetFileDocument(this.oid)
.then((fileDoc) => {
let content_type = fileDoc.content_type
if (content_type.startsWith('image')) {
try {
let imgFile = getFileVariation(fileDoc, 's');
this.thumbnail = imgFile.link;
} catch (error) {
this.thumbnailBackup = 'pi-image';
}
} else if(content_type.startsWith('video')) {
this.thumbnailBackup = 'pi-video';
} else {
this.thumbnailBackup = 'pi-file';
}
})
);
},
methods: {
/**
* Replaces all spaces with underscore and removes all o
* @param {String} unsafe
* @returns {String}
*/
makeSafeAttachmentString(unsafe) {
let candidate = (unsafe);
let matchSpace = / /g;
candidate = candidate
.replace(matchSpace, '_')
.replace(NON_VALID_NAME_REGEXP, '')
return candidate || `${this.oid}`
}
}
});

View File

@ -0,0 +1,168 @@
import '../user/Avatar'
import '../utils/PrettyCreated'
import './CommentEditor'
import './Rating'
import { Linkable } from '../mixins/Linkable'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const TEMPLATE = `
<div class="comment-branch">
<div class="comment-container"
:class="{'is-first': !isReply, 'is-reply': isReply, 'comment-linked': isLinked}"
:id="comment.id">
<div class="comment-avatar">
<user-avatar
:user="comment.user"
/>
<div class="user-badges"
v-html="comment.user.badges_html">
</div>
</div>
<div class="comment-content">
<div class="comment-body"
v-if="!isUpdating"
>
<p class="comment-author">
{{ comment.user.full_name }}
</p>
<span class="comment-msg">
<p v-html="comment.msg_html"/>
</span>
</div>
<comment-editor
v-if="isUpdating"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
<div class="comment-meta">
<comment-rating
:comment="comment"
@unit-of-work="childUnitOfWork"
/>
<div class="comment-action">
<span class="action" title="Reply to this comment"
v-if="canReply"
@click="showReplyEditor"
>
Reply
</span>
<span class="action" title="Edit comment"
v-if="canUpdate"
@click="showUpdateEditor"
>
Edit
</span>
<span class="action" title="Cancel changes"
v-if="canCancel"
@click="cancleEdit"
>
<i class="pi-cancel"></i>Cancel
</span>
</div>
<pretty-created
:created="comment.created"
:updated="comment.updated"
/>
</div>
</div>
</div>
<div class="comment-reply-container is-reply"
v-if="isReplying"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="isReplying"
@unit-of-work="childUnitOfWork"
:mode="editorMode"
:comment="comment"
:user="user"
:parentId="comment.id"
/>
</div>
<div class="comments-list">
<comment
v-for="c in comment.replies"
@unit-of-work="childUnitOfWork"
isReply=true
:readOnly="readOnly"
:comment="c"
:user="user"
:key="c.id"/>
</div>
</div>
`;
Vue.component('comment', {
template: TEMPLATE,
mixins: [Linkable, UnitOfWorkTracker],
props: {
user: Object,
comment: Object,
readOnly: {
type: Boolean,
default: false,
},
isReply: {
type: Boolean,
default: false,
},
},
data() {
return {
isReplying: false,
isUpdating: false,
id: this.comment.id,
}
},
computed: {
canUpdate() {
return !this.readOnly && this.comment.user.id === this.user.user_id && !this.isUpdating && !this.isReplying;
},
canReply() {
return !this.readOnly && !this.isUpdating && !this.isReplying;
},
canCancel() {
return this.isReplying || this.isUpdating;
},
editorMode() {
if(this.isReplying) {
return 'reply';
}
if(this.isUpdating) {
return 'update';
}
}
},
created() {
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$on(Events.EDIT_DONE, this.doHideEditors);
},
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.doHideEditors);
},
methods: {
showReplyEditor() {
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
this.isReplying = true;
},
showUpdateEditor() {
EventBus.$emit(Events.BEFORE_SHOW_EDITOR, this.comment.id );
this.isUpdating = true;
},
cancleEdit() {
this.doHideEditors();
EventBus.$emit(Events.EDIT_DONE, this.comment.id );
},
doHideEditors() {
this.isReplying = false;
this.isUpdating = false;
},
}
});

View File

@ -0,0 +1,330 @@
import '../utils/MarkdownPreview'
import './AttachmentEditor'
import './UploadProgress'
import { thenCreateComment, thenUpdateComment } from '../../api/comments'
import { thenUploadFile } from '../../api/files'
import { Droptarget } from '../mixins/Droptarget'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const MAX_ATTACHMENTS = 5;
const TEMPLATE =`
<div class="comment-reply-form"
:class="dropTargetClasses"
>
<div class="attachments">
<comment-attachment-editor
v-for="a in attachments"
@delete="attachmentDelete"
@insert="insertAttachment"
@rename="attachmentRename"
@validation="attachmentValidation"
@unit-of-work="childUnitOfWork"
:slug="a.slug"
:allSlugs="allSlugs"
:oid="a.oid"
:key="a.oid"
/>
<upload-progress
v-if="uploads.nbrOfActive > 0"
:label="uploadProgressLabel"
:progress="uploadProgressPercent"
/>
</div>
<div class="comment-reply-field"
:class="{filled: isMsgLongEnough}"
>
<textarea
ref="inputField"
@keyup="keyUp"
v-model="msg"
id="comment_field"
placeholder="Join the conversation...">
</textarea>
<div class="comment-reply-meta">
<button class="comment-action-submit"
:class="{disabled: !canSubmit}"
@click="submit"
type="button"
title="Post Comment (Ctrl+Enter)">
<span>
<i :class="submitButtonIcon"/>{{ submitButtonText }}
</span>
<span class="hotkey">Ctrl + Enter</span>
</button>
</div>
</div>
<markdown-preview
v-show="msg.length > 0"
:markdown="msg"
:attachments="attachmentsAsObject"
/>
</div>
`;
Vue.component('comment-editor', {
template: TEMPLATE,
mixins: [Droptarget, UnitOfWorkTracker],
props: {
user: Object,
parentId: String,
projectId: String,
comment: Object,
mode: {
type: String,
default: 'reply', // reply or update
},
},
data() {
return {
msg: this.initialMsg(),
attachments: this.initialAttachments(),
uploads: {
nbrOfActive: 0,
nbrOfTotal: 0,
total: 0,
loaded: 0
},
}
},
computed: {
submitButtonText() {
switch(this.mode) {
case 'reply': return 'Send';
case 'update': return 'Update';
default: console.error('Unknown mode: ', this.mode);
}
},
submitButtonIcon() {
if (this.isBusyWorking) {
return 'pi-spin spin';
}else{
switch(this.mode) {
case 'reply': return 'pi-paper-plane';
case 'update': return 'pi-check';
default: console.error('Unknown mode: ', this.mode);
}
}
},
attachmentsAsObject() {
let attachmentsObject = {};
for (let a of this.attachments) {
attachmentsObject[a.slug] = {oid: a.oid};
}
return attachmentsObject;
},
allSlugs() {
return this.attachments.map((a) => {
return a['slug'];
});
},
isMsgLongEnough() {
return this.msg.length >= 5;
},
isAttachmentsValid() {
for (let att of this.attachments) {
if(!att.isSlugValid) {
return false;
}
}
return true;
},
isValid() {
return this.isAttachmentsValid && this.isMsgLongEnough;
},
canSubmit() {
return this.isValid && !this.isBusyWorking;
},
uploadProgressPercent() {
if (this.uploads.nbrOfActive === 0 || this.uploads.total === 0) {
return 100;
}
return this.uploads.loaded / this.uploads.total * 100;
},
uploadProgressLabel() {
if (this.uploadProgressPercent === 100) {
return 'Processing'
}
if (this.uploads.nbrOfTotal === 1) {
return 'Uploading file';
} else {
let fileOf = this.uploads.nbrOfTotal - this.uploads.nbrOfActive + 1;
return `Uploading ${fileOf}/${this.uploads.nbrOfTotal} files`;
}
},
},
watch:{
msg(){
this.autoSizeInputField();
}
},
mounted() {
if(this.comment) {
this.$nextTick(function () {
this.autoSizeInputField();
this.$refs.inputField.focus();
})
}
},
methods: {
initialMsg() {
if (this.comment) {
if (this.mode === 'reply') {
return `***@${this.comment.user.full_name}*** `;
}
if (this.mode === 'update') {
return this.comment.msg_markdown;
}
}
return '';
},
initialAttachments() {
// Transforming the attacmentobject to an array of attachments
let attachmentsList = []
if(this.mode === 'update') {
let attachmentsObj = this.comment.properties.attachments
for (let k in attachmentsObj) {
if (attachmentsObj.hasOwnProperty(k)) {
let a = {
slug: k,
oid: attachmentsObj[k]['oid'],
isSlugValid: true
}
attachmentsList.push(a);
}
}
}
return attachmentsList;
},
submit() {
if(!this.canSubmit) return;
this.unitOfWork(
this.thenSubmit()
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to submit comment')})
);
},
thenSubmit() {
if (this.mode === 'reply') {
return this.thenCreateComment();
} else {
return this.thenUpdateComment();
}
},
keyUp(e) {
if ((e.keyCode == 13 || e.key === 'Enter') && e.ctrlKey) {
this.submit();
}
},
thenCreateComment() {
return thenCreateComment(this.parentId, this.msg, this.attachmentsAsObject)
.then((newComment) => {
EventBus.$emit(Events.NEW_COMMENT, newComment);
EventBus.$emit(Events.EDIT_DONE, newComment.id );
this.cleanUp();
})
},
thenUpdateComment() {
return thenUpdateComment(this.comment.parent, this.comment.id, this.msg, this.attachmentsAsObject)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
EventBus.$emit(Events.EDIT_DONE, updatedComment.id);
this.cleanUp();
})
},
canHandleDrop(event) {
let dataTransfer = event.dataTransfer;
let items = [...dataTransfer.items];
let nbrOfAttachments = items.length + this.uploads.nbrOfActive + this.attachments.length;
if(nbrOfAttachments > MAX_ATTACHMENTS) {
// Exceeds the limit
return false;
}
// Only files in drop
return [...dataTransfer.items].reduce((prev, it) => {
let isFile = it.kind === 'file' && !!it.type;
return prev && isFile;
}, !!items.length);
},
onDrop(event) {
let files = [...event.dataTransfer.files];
for (let f of files) {
this.unitOfWork(
this.thenUploadFile(f)
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'File upload failed')})
);
}
},
thenUploadFile(file){
let lastReportedTotal = 0;
let lastReportedLoaded = 0;
let progressCB = (total, loaded) => {
this.uploads.loaded += loaded - lastReportedLoaded;
this.uploads.total += total - lastReportedTotal;
lastReportedLoaded = loaded;
lastReportedTotal = total;
}
this.uploads.nbrOfActive++;
this.uploads.nbrOfTotal++;
return thenUploadFile(this.projectId || this.comment.project, file, progressCB)
.then((resp) => {
let attachment = {
slug: file.name,
oid: resp['file_id'],
isSlugValid: false
}
this.attachments.push(attachment);
this.msg += this.getAttachmentMarkdown(attachment);
})
.always(()=>{
this.uploads.nbrOfActive--;
if(this.uploads.nbrOfActive === 0) {
this.uploads.loaded = 0;
this.uploads.total = 0;
this.uploads.nbrOfTotal = 0;
}
})
},
getAttachmentMarkdown(attachment){
return `{attachment ${attachment.slug}}`;
},
insertAttachment(oid){
let attachment = this.getAttachment(oid);
this.msg += this.getAttachmentMarkdown(attachment);
},
attachmentDelete(oid) {
let attachment = this.getAttachment(oid);
let markdownToRemove = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(markdownToRemove,'g'), '');
this.attachments = this.attachments.filter((a) => {return a.oid !== oid});
},
attachmentRename(newName, oid) {
let attachment = this.getAttachment(oid);
let oldMarkdownAttachment = this.getAttachmentMarkdown(attachment);
attachment.slug = newName;
let newMarkdownAttachment = this.getAttachmentMarkdown(attachment);
this.msg = this.msg.replace(new RegExp(oldMarkdownAttachment,'g'), newMarkdownAttachment);
},
getAttachment(oid) {
for (let a of this.attachments) {
if (a.oid === oid) return a;
}
console.error('No attachment found:', oid);
},
attachmentValidation(oid, isValid) {
let attachment = this.getAttachment(oid);
attachment.isSlugValid = isValid;
},
cleanUp() {
this.msg = '';
this.attachments = [];
},
autoSizeInputField() {
let elInputField = this.$refs.inputField;
elInputField.style.cssText = 'height:auto; padding:0';
let newInputHeight = elInputField.scrollHeight + 20;
elInputField.style.cssText = `height:${ newInputHeight }px`;
}
}
});

View File

@ -0,0 +1,157 @@
import './CommentEditor'
import './Comment'
import './CommentsLocked'
import '../user/Avatar'
import '../utils/GenericPlaceHolder'
import { thenGetComments } from '../../api/comments'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { EventBus, Events } from './EventBus'
const TEMPLATE = `
<section class="comments-tree">
<div class="comment-reply-container"
v-if="canReply"
>
<user-avatar
:user="user"
/>
<comment-editor
v-if="canReply"
mode="reply"
@unit-of-work="childUnitOfWork"
:projectId="projectId"
:parentId="parentId"
:user="user"
/>
</div>
<comments-locked
v-if="readOnly||!isLoggedIn"
:user="user"
/>
<div class="comments-list-title">{{ numberOfCommentsStr }}</div>
<div class="comments-list">
<comment
v-for="c in comments"
@unit-of-work="childUnitOfWork"
:readOnly=readOnly||!isLoggedIn
:comment="c"
:user="user"
:key="c.id"/>
</div>
<generic-placeholder
v-show="showLoadingPlaceholder"
label="Loading Comments..."
/>
</section>
`;
Vue.component('comments-tree', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
parentId: String,
readOnly: {
type: Boolean,
default: false
}
},
data() {
return {
replyHidden: false,
nbrOfComments: 0,
projectId: '',
comments: [],
showLoadingPlaceholder: true,
user: pillar.utils.getCurrentUser(),
canPostComments: this.canPostCommentsStr == 'true'
}
},
computed: {
numberOfCommentsStr() {
let pluralized = this.nbrOfComments === 1 ? 'Comment' : 'Comments'
return `${ this.nbrOfComments } ${ pluralized }`;
},
isLoggedIn() {
return this.user.is_authenticated;
},
iSubscriber() {
return this.user.hasCap('subscriber');
},
canRenewSubscription() {
return this.user.hasCap('can-renew-subscription');
},
canReply() {
return !this.readOnly && !this.replyHidden && this.isLoggedIn;
}
},
watch: {
isBusyWorking(isBusy) {
if(isBusy) {
$(document).trigger('pillar:workStart');
} else {
$(document).trigger('pillar:workStop');
}
}
},
created() {
EventBus.$on(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$on(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$on(Events.NEW_COMMENT, this.onNewComment);
EventBus.$on(Events.UPDATED_COMMENT, this.onCommentUpdated);
this.unitOfWork(
thenGetComments(this.parentId)
.then((commentsTree) => {
this.nbrOfComments = commentsTree['nbr_of_comments'];
this.comments = commentsTree['comments'];
this.projectId = commentsTree['project'];
})
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Failed to load comments')})
.always(()=>this.showLoadingPlaceholder = false)
);
},
beforeDestroy() {
EventBus.$off(Events.BEFORE_SHOW_EDITOR, this.doHideEditors);
EventBus.$off(Events.EDIT_DONE, this.showReplyComponent);
EventBus.$off(Events.NEW_COMMENT, this.onNewComment);
EventBus.$off(Events.UPDATED_COMMENT, this.onCommentUpdated);
},
methods: {
doHideEditors() {
this.replyHidden = true;
},
showReplyComponent() {
this.replyHidden = false;
},
onNewComment(newComment) {
this.nbrOfComments++;
let parentArray;
if(newComment.parent === this.parentId) {
parentArray = this.comments;
} else {
let parentComment = this.findComment(this.comments, (comment) => {
return comment.id === newComment.parent;
});
parentArray = parentComment.replies;
}
parentArray.unshift(newComment);
},
onCommentUpdated(updatedComment) {
let commentInTree = this.findComment(this.comments, (comment) => {
return comment.id === updatedComment.id;
});
delete updatedComment.replies; // No need to apply these since they should be the same
Object.assign(commentInTree, updatedComment);
},
findComment(arrayOfComments, matcherCB) {
for(let comment of arrayOfComments) {
if(matcherCB(comment)) {
return comment;
}
let match = this.findComment(comment.replies, matcherCB);
if (match) {
return match;
}
}
}
},
});

View File

@ -0,0 +1,53 @@
const TEMPLATE = `
<div class="comments-locked">
<div
v-if="msgToShow === 'PROJECT_MEMBERS_ONLY'"
>
<i class="pi-lock"/>
Only project members can comment.
</div>
<div
v-if="msgToShow === 'RENEW'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="/renew" target="_blank"> Renew your subscription </a>
to comment.
</div>
<div
v-if="msgToShow === 'JOIN'"
>
<i class="pi-heart"/>
Join the conversation!
<a href="https://store.blender.org/product/membership/" target="_blank"> Subscribe to Blender Cloud </a>
to comment.
</div>
<div
v-if="msgToShow === 'LOGIN'"
>
<a href="/login"> Log in to comment</a>
</div>
</div>
`;
Vue.component('comments-locked', {
template: TEMPLATE,
props: {user: Object},
computed: {
msgToShow() {
if(this.user && this.user.is_authenticated) {
if (this.user.hasCap('subscriber')) {
return 'PROJECT_MEMBERS_ONLY';
} else if(this.user.hasCap('can-renew-subscription')) {
return 'RENEW';
} else {
return 'JOIN';
}
}
return 'LOGIN';
}
},
});

View File

@ -0,0 +1,7 @@
export const Events = {
NEW_COMMENT: 'new-comment',
UPDATED_COMMENT: 'updated-comment',
EDIT_DONE: 'edit-done',
BEFORE_SHOW_EDITOR: 'before-show-editor'
}
export const EventBus = new Vue();

View File

@ -0,0 +1,52 @@
import { EventBus, Events } from './EventBus'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
import { thenVoteComment } from '../../api/comments'
const TEMPLATE = `
<div class="comment-rating"
:class="{rated: hasRating, positive: isPositive }"
>
<div class="comment-rating-value" title="Number of likes">{{ rating }}</div>
<div class="comment-action-rating up" title="Like comment"
v-if="canVote"
@click="upVote"
/>
</div>
`;
Vue.component('comment-rating', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {comment: Object},
computed: {
positiveRating() {
return this.comment.properties.rating_positive || 0;
},
negativeRating() {
return this.comment.properties.rating_negative || 0;
},
rating() {
return this.positiveRating - this.negativeRating;
},
isPositive() {
return this.rating > 0;
},
hasRating() {
return (this.positiveRating || this.negativeRating) !== 0;
},
canVote() {
return this.comment.user.id !== pillar.utils.getCurrentUser().user_id;
}
},
methods: {
upVote() {
let vote = this.comment.current_user_rating === true ? 0 : 1; // revoke if set
this.unitOfWork(
thenVoteComment(this.comment.parent, this.comment.id, vote)
.then((updatedComment) => {
EventBus.$emit(Events.UPDATED_COMMENT, updatedComment);
})
.fail((err) => {toastr.error(pillar.utils.messageFromError(err), 'Faied to vote on comment')})
);
}
}
});

View File

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

View File

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

View File

@ -0,0 +1,86 @@
/**
* Vue mixin that makes the component a droptarget
* override canHandleDrop(event) and onDrop(event)
* dragOverClasses can be bound to target class
*/
var Droptarget = {
data() {
return {
droptargetCounter: 0,
droptargetCanHandle: false
}
},
computed: {
isDragingOver() {
return this.droptargetCounter > 0;
},
dropTargetClasses() {
return {
'drag-hover': this.isDragingOver,
'unsupported-drop': this.isDragingOver && !this.droptargetCanHandle
}
}
},
mounted() {
this.$nextTick(function () {
this.$el.addEventListener('dragenter', this._onDragEnter);
this.$el.addEventListener('dragleave', this._onDragLeave);
this.$el.addEventListener('dragend', this._onDragEnd);
this.$el.addEventListener('dragover', this._onDragOver);
this.$el.addEventListener('drop', this._onDrop);
});
},
beforeDestroy() {
this.$el.removeEventListener('dragenter', this._onDragEnter);
this.$el.removeEventListener('dragleave', this._onDragLeave);
this.$el.removeEventListener('dragend', this._onDragEnd);
this.$el.removeEventListener('dragover', this._onDragOver);
this.$el.removeEventListener('drop', this._onDrop);
},
methods: {
canHandleDrop(event) {
throw Error('Not implemented');
},
onDrop(event) {
throw Error('Not implemented');
},
_onDragEnter(event) {
event.preventDefault();
event.stopPropagation();
this.droptargetCounter++;
if(this.droptargetCounter === 1) {
try {
this.droptargetCanHandle = this.canHandleDrop(event);
} catch (error) {
console.warn(error);
this.droptargetCanHandle = false;
}
}
},
_onDragLeave() {
this.droptargetCounter--;
},
_onDragEnd() {
this.droptargetCounter = 0;
},
_onDragOver() {
event.preventDefault();
event.stopPropagation();
},
_onDrop(event) {
event.preventDefault();
event.stopPropagation();
if(this.droptargetCanHandle) {
try {
this.onDrop(event);
} catch (error) {
console.console.warn(error);
}
}
this.droptargetCounter = 0;
},
}
}
export { Droptarget }

View File

@ -0,0 +1,24 @@
/**
* Vue mixin that scrolls element into view if id matches #value in url
* @param {String} id identifier that is set by the user of the mixin
* @param {Boolean} isLinked true if Component is linked
*/
let hash = window.location.hash.substr(1).split('?')[0];
var Linkable = {
data() {
return {
id: '',
isLinked: false,
}
},
mounted: function () {
this.$nextTick(function () {
if(hash && this.id === hash) {
this.isLinked = true;
this.$el.scrollIntoView({ behavior: 'smooth' });
}
})
}
}
export { Linkable }

View File

@ -0,0 +1,59 @@
/**
* Vue helper mixin to keep track if work is in progress or not.
* Example use:
* Keep track of work in own component:
* this.unitOfWork(
* thenDostuff()
* .then(...)
* .fail(...)
* );
*
* Keep track of work in child components:
* <myChild
* @unit-of-work="childUnitOfWork"
* />
*
* Use the information to enable class:
* <div :class="{disabled: 'isBusyWorking'}">
*/
var UnitOfWorkTracker = {
data() {
return {
unitOfWorkCounter: 0,
}
},
computed: {
isBusyWorking() {
if(this.unitOfWorkCounter < 0) {
console.error('UnitOfWork missmatch!')
}
return this.unitOfWorkCounter > 0;
}
},
watch: {
isBusyWorking(isBusy) {
if(isBusy) {
this.$emit('unit-of-work', 1);
} else {
this.$emit('unit-of-work', -1);
}
}
},
methods: {
unitOfWork(promise) {
this.unitOfWorkBegin();
return promise.always(this.unitOfWorkDone);
},
unitOfWorkBegin() {
this.unitOfWorkCounter++;
},
unitOfWorkDone() {
this.unitOfWorkCounter--;
},
childUnitOfWork(direction) {
this.unitOfWorkCounter += direction;
}
}
}
export { UnitOfWorkTracker }

View File

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

View File

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

View File

@ -0,0 +1,56 @@
import { debounced } from '../../utils/init'
import { thenMarkdownToHtml } from '../../api/markdown'
import { UnitOfWorkTracker } from '../mixins/UnitOfWorkTracker'
const TEMPLATE = `
<div class="markdown-preview">
<div class="markdown-preview-md"
v-html="asHtml"/>
<div class="markdown-preview-info">
<a
title="Handy guide of Markdown syntax"
target="_blank"
href="http://commonmark.org/help/">
<span>markdown cheatsheet</span>
</a>
</div>
</div>
`;
Vue.component('markdown-preview', {
template: TEMPLATE,
mixins: [UnitOfWorkTracker],
props: {
markdown: String,
attachments: Object
},
data() {
return {
asHtml: '',
}
},
created() {
this.markdownToHtml(this.markdown, this.attachments);
this.debouncedMarkdownToHtml = debounced(this.markdownToHtml);
},
watch: {
markdown(newValue, oldValue) {
this.debouncedMarkdownToHtml(newValue, this.attachments);
},
attachments(newValue, oldValue) {
this.debouncedMarkdownToHtml(this.markdown, newValue);
}
},
methods: {
markdownToHtml(markdown, attachments) {
this.unitOfWork(
thenMarkdownToHtml(markdown, attachments)
.then((data) => {
this.asHtml = data.content;
})
.fail((err) => {
toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
})
);
}
}
});

View File

@ -0,0 +1,33 @@
import { prettyDate } from '../../utils/init'
const TEMPLATE =
`<div class="pretty-created" :title="'Posted ' + created">
{{ prettyCreated }}
<span
v-if="isEdited"
:title="'Updated ' + prettyUpdated"
>*</span>
</div>
`;
Vue.component('pretty-created', {
template: TEMPLATE,
props: {
created: String,
updated: String,
detailed: {
type: Boolean,
default: true
}
},
computed: {
prettyCreated() {
return prettyDate(this.created, this.detailed);
},
prettyUpdated() {
return prettyDate(this.updated, this.detailed);
},
isEdited() {
return this.updated && (this.created !== this.updated)
}
}
});

View File

@ -1,323 +0,0 @@
/* Reply */
$(document).on('click','body .comment-action-reply',function(e){
e.preventDefault();
// container of the comment we are replying to
var parentDiv = $(this).closest('.comment-container');
// container of the first-level comment in the thread
var parentDivFirst = parentDiv.prevAll('.is-first:first');
// Get the id of the comment
if (parentDiv.hasClass('is-reply')) {
parentNodeId = parentDivFirst.data('node-id');
} else {
parentNodeId = parentDiv.data('node-id');
}
if (!parentNodeId) {
if (console) console.log('No parent ID found on ', parentDiv.toArray(), parentDivFirst.toArray());
return;
}
// Get the textarea and set its parent_id data
var commentField = document.getElementById('comment_field');
commentField.dataset.originalParentId = commentField.dataset.parentId;
commentField.dataset.parentId = parentNodeId;
// Start the comment field with @authorname:
var replyAuthor = parentDiv.find('.comment-author:first').html();
$(commentField).val("**@" + replyAuthor + ":** ");
// Add class for styling
$('.comment-container').removeClass('is-replying');
parentDiv.addClass('is-replying');
// Move comment-reply container field after the parent container
var commentForm = $('.comment-reply-container').detach();
parentDiv.after(commentForm);
// document.getElementById('comment_field').focus();
$(commentField).focus();
$('.comment-reply-field').addClass('filled');
});
/* Cancel Reply */
$(document).on('click','body .comment-action-cancel',function(e){
e.preventDefault();
$('.comment-reply-container').detach().prependTo('#comments-list');
var commentField = document.getElementById('comment_field');
commentField.dataset.parentId = commentField.dataset.originalParentId;
delete commentField.dataset.originalParentId;
$(commentField).val('');
$('.comment-reply-field').removeClass('filled');
$('.comment-container').removeClass('is-replying');
});
/* Rate */
$(document).on('click','body .comment-action-rating',function(e){
e.preventDefault();
var $this = $(this);
var nodeId = $this.closest('.comment-container').data('node-id');
var is_positive = !$this.hasClass('down');
var parentDiv = $this.parent();
var rated_positive = parentDiv.hasClass('positive');
if (typeof nodeId === 'undefined') {
if (console) console.log('Undefined node ID');
return;
}
var op;
if (parentDiv.hasClass('rated') && is_positive == rated_positive) {
op = 'revoke';
} else if (is_positive) {
op = 'upvote';
} else {
op = 'downvote';
}
$.post("/nodes/comments/" + nodeId + "/rate/" + op)
.done(function(data){
// Add/remove styles for rated statuses
switch(op) {
case 'revoke':
parentDiv.removeClass('rated');
break;
case 'upvote':
parentDiv.addClass('rated');
parentDiv.addClass('positive');
break;
case 'downvote':
parentDiv.addClass('rated');
parentDiv.removeClass('positive');
break;
}
var rating = data['data']['rating_positive'] - data['data']['rating_negative'];
$this.siblings('.comment-rating-value').text(rating);
});
});
/**
* Fetches a comment, returns a promise object.
*/
function loadComment(comment_id, projection)
{
if (typeof comment_id === 'undefined') {
console.log('Error, loadComment(', comment_id, ', ', projection, ') called.');
return $.Deferred().reject();
}
// Add required projections for the permission system to work.
projection.node_type = 1;
projection.project = 1;
var url = '/api/nodes/' + comment_id;
return $.get({
url: url,
data: {projection: projection},
cache: false, // user should be ensured the latest comment to edit.
});
}
function loadComments(commentsUrl)
{
var commentsContainer = $('#comments-embed');
return $.get(commentsUrl)
.done(function(dataHtml) {
// Update the DOM injecting the generate HTML into the page
commentsContainer.html(dataHtml);
$('body').trigger('pillar:comments-loaded');
})
.fail(function(xhr) {
toastr.error('Could not load comments', xhr.responseText);
commentsContainer.html('<a id="comments-reload"><i class="pi-refresh"></i> Reload comments</a>');
});
}
/**
* Shows an error in the "Post Comment" button.
*/
function show_comment_button_error(msg) {
var $button = $('.comment-action-submit');
var $textarea = $('#comment_field');
$button.addClass('error');
$textarea.addClass('error');
$button.html(msg);
setTimeout(function(){
$button.html('<span><i class="pi-paper-plane"></i> Send</span>');
$button.removeClass('error');
$textarea.removeClass('error');
}, 2500);
}
/**
* Shows an error in the "edit comment" button.
*/
function show_comment_edit_button_error($button, msg) {
var $textarea = $('#comment_field');
$button.addClass('error');
$textarea.addClass('field-error');
$button.html(msg);
setTimeout(function(){
$button.html('<i class="pi-check"></i> Save Changes');
$button.removeClass('button-field-error');
$textarea.removeClass('field-error');
}, 2500);
}
/**
* Switches the comment to either 'edit' or 'view' mode.
*/
function comment_mode(clicked_item, mode)
{
var $container = $(clicked_item).closest('.comment-container');
var comment_id = $container.data('node-id');
var $edit_buttons = $container.find('.comment-action-edit');
if (mode == 'edit') {
$edit_buttons.find('.edit_mode').hide();
$edit_buttons.find('.edit_cancel').show();
$edit_buttons.find('.edit_save').show();
} else {
$edit_buttons.find('.edit_mode').show();
$edit_buttons.find('.edit_cancel').hide();
$edit_buttons.find('.edit_save').hide();
$container.find('.comment-body').removeClass('editing');
}
}
/**
* Return UI to normal, when cancelling or saving.
*
* clicked_item: save/cancel button.
*
* Returns a promise on the comment loading if reload_comment=true.
*/
function commentEditCancel(clicked_item, reload_comment) {
comment_mode(clicked_item, 'view');
var comment_container = $(clicked_item).closest('.comment-container');
var comment_id = comment_container.data('node-id');
if (!reload_comment) return;
return loadComment(comment_id, {'properties.content': 1})
.done(function(data) {
var comment_html = data['properties']['content_html'];
comment_container
.find('.comment-body span')
.removeClass('editing')
.html(comment_html);
})
.fail(function(xhr) {
if (console) console.log('Error fetching comment: ', xhr);
toastr.error('Error canceling', xhr.responseText);
});
}
function save_comment(is_new_comment, $commentContainer)
{
var promise = $.Deferred();
var commentField;
var commentId;
var parent_id;
// Get data from HTML, and validate it.
if (is_new_comment)
commentField = $('#comment_field');
else {
commentField = $commentContainer.find('textarea');
commentId = $commentContainer.data('node-id');
}
if (!commentField.length)
return promise.reject("Unable to find comment field.");
if (is_new_comment) {
parent_id = commentField.data('parent-id');
if (!parent_id) {
if (console) console.log("No parent ID found in comment field data.");
return promise.reject("No parent ID!");
}
}
// Validate the comment itself.
var comment = commentField.val();
if (comment.length < 5) {
if (comment.length == 0) promise.reject("Say something...");
else promise.reject("Minimum 5 characters.");
return promise;
}
// Notify callers of the fact that client-side validation has passed.
promise.notify();
// Actually post the comment.
if (is_new_comment) {
$.post('/nodes/comments/create',
{'content': comment, 'parent_id': parent_id})
.fail(promise.reject)
.done(function(data) { promise.resolve(data.node_id, comment); });
} else {
$.post('/nodes/comments/' + commentId,
{'content': comment})
.fail(promise.reject)
.done(function(resp) {
promise.resolve(commentId, resp.data.content_html);
});
}
return promise;
}
/* Used when clicking the .comment-action-submit button or by a shortcut */
function post_comment($submit_button){
save_comment(true)
.progress(function() {
$submit_button
.addClass('submitting')
.html('<span><i class="pi-spin spin"></i> Sending...</span>');
})
.fail(function(xhr){
if (typeof xhr === 'string') {
show_comment_button_error(xhr);
} else {
// If it's not a string, we assume it's a jQuery XHR object.
if (console) console.log('Error posting comment: ', xhr.responseText);
show_comment_button_error("Try again?");
}
toastr.error(xhr.responseText, 'Error posting comment');
})
.done(function(comment_node_id) {
$submit_button
.removeClass('submitting')
.html('<i class="pi-paper-plane"></i> Send');
$('#comment_field').val('');
$('body').trigger('pillar:comment-posted', [comment_node_id]);
toastr.success('Comment posted!');
});
}

View File

@ -1,27 +1,11 @@
$comments-width-max: 710px
.comments-container
.comments-tree
max-width: $comments-width-max
position: relative
width: 100%
#comments-reload
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
.comments-list-title
padding: 15px 0 10px 0
font-size: 1.1em
font-weight: 300
@ -49,22 +33,6 @@ $comments-width-max: 710px
right: 20px
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
color: $color-text-dark
display: inline-block
@ -76,7 +44,7 @@ $comments-width-max: 710px
&.op
color: $color-primary-dark
.comment-time
.pretty-created
padding-left: 10px
margin-left: 10px
color: $color-text-dark-hint
@ -133,29 +101,28 @@ $comments-width-max: 710px
padding-top: inherit
padding-bottom: inherit
.editing
background-color: $color-background-light
display: flex
margin: 30px 0 10px
textarea
background-color: $color-background
border-radius: 3px
border: none
box-shadow: inset 0 0 2px 0 rgba(darken($color-background-dark, 30%), .5)
display: block
padding: 10px
width: 100%
&:focus
box-shadow: inset 0 0 2px 0 darken($color-background-dark, 30%)
.comment-content
display: flex
flex-direction: column
padding-bottom: 0
width: 100%
&.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 */
.comment-meta
+media-xs
@ -184,7 +151,7 @@ $comments-width-max: 710px
color: $color-text-dark-secondary
.comment-action-rating.up:before
content: '\e83f'
content: '\e83f' // Heart filled
.comment-rating-value
@ -202,28 +169,22 @@ $comments-width-max: 710px
&:hover
color: $color-upvote
&:before
content: '\e83e'
content: '\e83e' // Heart outlined
top: 2px
position: relative
.comment-action-rating.down
&:hover
color: $color-downvote
&:before
content: '\e838'
/* Reply button */
.comment-action-reply
.comment-action
color: $color-primary
&:hover
text-decoration: underline
.comment-action-reply,
.comment-action-edit
padding-left: 10px
margin-left: 10px
.action
cursor: pointer
margin-left: 15px
&:hover
color: $color-primary
text-decoration: underline
&:before
color: $color-text-dark-secondary
content: '·'
@ -232,97 +193,39 @@ $comments-width-max: 710px
position: relative
text-decoration: none
span
cursor: pointer
&:hover
color: $color-primary
.attachments
display: flex
flex-direction: column
.attachment
display: flex
flex-direction: row
align-items: center
span.edit_save,
color: $color-success
display: none
&:hover
color: lighten($color-success, 10%)
.preview-thumbnail
margin: 0.1em
.actions
margin-left: auto
.action
cursor: pointer
color: $color-primary
margin-left: 2em
&.delete
color: $color-danger
input
max-height: 2em
&.error
color: $color-danger
input
border-color: $color-danger
&.saving
user-select: none
pointer-events: none
.comments-locked
display: block
padding: 10px
color: $color-text-dark-primary
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
/* Little gravatar icon on the left */
&-avatar
img
border-radius: 50%
width: 25px
height: 25px
box-shadow: 0 0 0 3px $color-background-light
/* textarea field, submit button and reply details */
&-form
padding:
@ -354,7 +257,8 @@ $comments-width-max: 710px
margin: 0
min-height: 35px
padding: 10px 0 10px 10px
resize: vertical
resize: none
overflow: hidden
transition: box-shadow 250ms ease-in-out
width: 100%
@ -380,9 +284,6 @@ $comments-width-max: 710px
&:focus
border-bottom-left-radius: 0
&+.comment-reply-preview
display: flex
.comment-reply-meta
background-color: $color-success
@ -393,49 +294,8 @@ $comments-width-max: 710px
span.hotkey
display: block
&.sign-in
display: block
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
.comment-action
.action
cursor: pointer
padding: 10px
text-decoration: underline
@ -489,19 +349,43 @@ $comments-width-max: 710px
font-weight: normal
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 */
.comment-container + .comment-reply-container
margin-left: 30px
padding-top: 10px
p
padding-left: 0
padding-right: 0
.comment-reply-form
.comment-reply-meta
button.comment-action-cancel
display: inline-block
&-md
flex: 1
padding: 5px 10px
&-info
background-color: $color-background-dark
display: flex
flex-direction: column
font-size: .8em
padding-bottom: 10px
text-align: center
width: 100px
.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
padding: 0
margin: 4px 0 0 0
@ -516,3 +400,28 @@ $comments-width-max: 710px
img
width: 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

View File

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

View File

@ -30,6 +30,8 @@ html(lang="en")
| {% endblock %}
script(src="{{ url_for('static_pillar', filename='assets/js/tutti.min.js') }}")
script.
pillar.utils.initCurrentUser({{ current_user | json | safe }});
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/jquery.typeahead-0.11.1.min.js')}}")
script(src="{{ url_for('static_pillar', filename='assets/js/vendor/js.cookie-2.0.3.min.js')}}")
| {% if current_user.is_authenticated %}

View File

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

View File

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

View File

@ -1,51 +0,0 @@
| {%- macro render_comment(comment, is_reply) -%}
.comment-container(
id="{{ comment._id }}",
data-node-id="{{ comment._id }}",
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
.comment-avatar
img(src="{{ comment._user.email | gravatar }}", alt="{{ comment._user.full_name }}")
.comment-badges
| {{ comment._user.badges_html|safe }}
.comment-content
.comment-body
p.comment-author {{ comment._user.full_name }}
span {{comment.properties | markdowned('content') }}
| {# TODO(Pablo): Markdown preview when editing #}
.comment-meta
.comment-rating(
class="{% if comment._current_user_rating is not none %}rated{% if comment._current_user_rating %} positive{% endif %}{% endif %}")
.comment-rating-value(title="{{ _('Number of likes') }}") {{ comment._rating }}
| {% if not comment._is_own %}
.comment-action-rating.up(title="{{ _('Like comment') }}")
| {% endif %}
.comment-action-reply(title="{{ _('Reply to this comment') }}")
span {{ _("Reply") }}
| {% if comment._is_own %}
.comment-action-edit
span.edit_mode(title="{{ _('Edit comment') }}")
| {{ _("Edit") }}
span.edit_save(title="{{ _('Save comment') }}")
i.pi-check
| {{ _("Save Changes") }}
span.edit_cancel(title="{{ _('Cancel changes') }}")
i.pi-cancel
| {{ _("Cancel") }}
| {% endif %}
.comment-time(title='{{ comment._created }}')
| {{ comment._created | pretty_date_time }}
| {% if comment._created != comment._updated %}
span(title="{{ _('edited') }} {{ comment._updated | pretty_date_time }}") *
| {% endif %}
| {% for reply in comment['_replies']['_items'] %}
| {{ render_comment(reply, True) }}
| {% endfor %}
| {%- endmacro -%}

View File

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

View File

@ -1,258 +0,0 @@
| {% import 'nodes/custom/comment/_macros.html' as macros %}
#comments.comments-container
section#comments-list.comments-list
.comment-reply-container
| {% if can_post_comments %}
| {% block can_post_comment %}
.comment-reply-avatar
img(
title="{{ _('Commenting as') }} {{ current_user.full_name }}",
src="{{ current_user.gravatar }}")
.comment-reply-form
.comment-reply-field
textarea(
id="comment_field",
data-parent-id="{{ node_id }}",
placeholder="{{ _('Join the conversation...') }}")
.comment-reply-meta
button.comment-action-submit(
id="comment_submit",
type="button",
title="Post Comment (Ctrl+Enter)")
span
i.pi-paper-plane
| {{ _('Send') }}
span.hotkey Ctrl + Enter
.comment-reply-preview
.comment-reply-preview-md
.comment-reply-info
.comment-action-cancel(
title="{{ _('cancel') }}")
span {{ _('cancel') }}
a(
title="{{ _('Handy guide of Markdown syntax') }}",
target="_blank",
href="http://commonmark.org/help/")
span {{ _('markdown cheatsheet') }}
| {% endblock can_post_comment %}
| {% else %}
| {% block can_not_post_comment %}
| {% if current_user.is_authenticated %}
| {# * User is authenticated, but has no subscription or 'POST' permission #}
.comment-reply-form
.comment-reply-field.sign-in
| {% if current_user.has_cap('subscriber') %}
i.pi-lock
| Only project members can comment.
| {% elif current_user.has_cap('can-renew-subscription') %}
i.pi-heart
| Join the conversation! #[a(href='/renew', target='_blank') Renew your subscription] to comment.
| {% else %}
| Join the conversation! #[a(href="https://store.blender.org/product/membership/") Subscribe to Blender Cloud] to comment.
| {% endif %}
| {% else %}
| {# * User is not autenticated #}
.comment-reply-form
.comment-reply-field.sign-in
a(href="{{ url_for('users.login') }}") {{ _('Log in to comment') }}
| {% endif %}
| {% endblock can_not_post_comment %}
| {% endif %}
| {% if show_comments and (nr_of_comments > 0) %}
section.comments-list-header
.comments-list-title
| {{ nr_of_comments }} {{ _('comment') }}{{ nr_of_comments|pluralize }}
.comments-list-items
| {% for comment in comments['_items'] %}
| {{ macros.render_comment(comment, False) }}
| {% endfor %}
| {% endif %}
| {% block comment_scripts %}
script.
{% if show_comments %}
$('body')
.off('pillar:comment-posted')
.on('pillar:comment-posted', function(e, comment_node_id) {
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node_id) }}";
loadComments(commentsUrl)
.done(function() {
$('#' + comment_node_id).scrollHere();
});
});
// If there's a comment link in the URL, scroll there
function scrollToLinkedComment() {
var scrollToId = location.hash;
// Check that it's a valid ObjectID before passing it to jQuery.
if (!/^[a-fA-F0-9]{24}$/.test(scrollToId.replace('#',''))) return;
$(scrollToId)
.addClass('comment-linked')
.scrollHere();
}
$(scrollToLinkedComment);
{% endif %}
{% if can_post_comments %}
// If we can actually comment, load the tools to do it
// Submit new comment
$(document)
.off('click','body .comment-action-submit')
.on( 'click','body .comment-action-submit', function(e){
post_comment($(this));
});
// Writing comment
var $commentField = $("#comment_field");
var $commentContainer = $commentField.parent();
var $commentPreview = $commentField.parent().parent().find('.comment-reply-preview-md');
function parseCommentContent(content) {
$.ajax({
url: "{{ url_for('nodes.preview_markdown')}}",
type: 'post',
data: {content: content},
headers: {"X-CSRFToken": csrf_token},
headers: {},
dataType: 'json'
})
.done(function (data) {
$commentPreview.html(data.content);
})
.fail(function (err) {
toastr.error(xhrErrorResponseMessage(err), 'Parsing failed');
});
}
var options = {
callback: parseCommentContent,
wait: 750,
highlight: false,
allowSubmit: false,
captureLength: 2
}
$commentField.typeWatch(options);
$(document)
.off('keyup','body .comment-reply-field textarea')
.on( 'keyup','body .comment-reply-field textarea',function(e){
// While we are at it, style if empty
if ($commentField.val()) {
$commentContainer.addClass('filled');
} else {
$commentContainer.removeClass('filled');
}
// Send on ctrl+enter
if ($commentField.is(":focus")) {
if ((e.keyCode == 10 || e.keyCode == 13) && e.ctrlKey){
post_comment($commentContainer.find('.comment-action-submit'));
}
}
});
// Autoresize the textarea as we type
$('#comment_field').autoResize();
// Edit comment
// Enter edit mode
$(document)
.off('click','body .comment-action-edit span.edit_mode')
.on( 'click','body .comment-action-edit span.edit_mode',function(){
comment_mode(this, 'edit');
var $parent_div = $(this).closest('.comment-container');
var comment_id = $parent_div.data('node-id');
var comment_content = $parent_div.find('.comment-body span');
var height = comment_content.height();
loadComment(comment_id, {'properties.content': 1})
.done(function(data) {
var comment_raw = data['properties']['content'];
comment_content.html($('<textarea>').text(comment_raw));
comment_content
.addClass('editing')
.find('textarea')
.autoResize()
.focus()
.trigger('keyup');
})
.fail(function(xhr) {
if (console) console.log('Error fetching comment: ', xhr);
toastr.error('Error ' + xhr.status + ' entering edit mode.');
});
});
// Abort, abort
$(document)
.off('click','body .comment-action-edit span.edit_cancel')
.on( 'click','body .comment-action-edit span.edit_cancel',function(e){
commentEditCancel(this, true);
});
// Save edited comment
$(document)
.off('click','body .comment-action-edit span.edit_save')
.on( 'click','body .comment-action-edit span.edit_save',function(e){
var $button = $(this);
var $container = $button.closest('.comment-container');
save_comment(false, $container)
.progress(function() {
$button
.addClass('submitting')
.html('<i class="pi-spin spin"></i> Posting...');
})
.fail(function(xhr) {
if (typeof xhr === 'string') {
show_comment_edit_button_error($button, xhr);
} else {
// If it's not a string, we assume it's a jQuery XHR object.
if (console) console.log('Error saving comment:', xhr.responseText);
show_comment_edit_button_error($button, "Houston! Try again?");
}
})
.done(function(comment_id, comment_html) {
commentEditCancel($button, false)
$container.find('.comment-body span')
.html(comment_html)
.removeClass('editing')
.flashOnce();
$button
.html('<i class="pi-check"></i> save changes')
.removeClass('saving');
});
});
{% endif %}
| {% endblock %}

View File

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

View File

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