Major revision of comment system.
- Comments are stored in HTML as well as Markdown, so that conversion only happens when saving (rather than when viewing). - Added 'markdown' Jinja filter for easy development. This is quite a heavy filter, so it shouldn't be used (much) in production. - Added CLI command to update schemas on existing node types.
This commit is contained in:
@@ -6,6 +6,11 @@ node_type_comment = {
|
|||||||
'content': {
|
'content': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'minlength': 5,
|
'minlength': 5,
|
||||||
|
'required': True,
|
||||||
|
},
|
||||||
|
# The converted-to-HTML content.
|
||||||
|
'content_html': {
|
||||||
|
'type': 'string',
|
||||||
},
|
},
|
||||||
'status': {
|
'status': {
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
node_type_group = {
|
node_type_group = {
|
||||||
'name': 'group',
|
'name': 'group',
|
||||||
'description': 'Generic group node type edited',
|
'description': 'Folder node type',
|
||||||
'parent': ['group', 'project'],
|
'parent': ['group', 'project'],
|
||||||
'dyn_schema': {
|
'dyn_schema': {
|
||||||
# Used for sorting within the context of a group
|
# Used for sorting within the context of a group
|
||||||
|
@@ -8,6 +8,8 @@ import rsa.randnum
|
|||||||
import werkzeug.exceptions as wz_exceptions
|
import werkzeug.exceptions as wz_exceptions
|
||||||
from bson import ObjectId
|
from bson import ObjectId
|
||||||
from flask import current_app, g, Blueprint, request
|
from flask import current_app, g, Blueprint, request
|
||||||
|
|
||||||
|
import pillar.markdown
|
||||||
from pillar.api import file_storage
|
from pillar.api import file_storage
|
||||||
from pillar.api.activities import activity_subscribe, activity_object_add
|
from pillar.api.activities import activity_subscribe, activity_object_add
|
||||||
from pillar.api.utils.algolia import algolia_index_node_delete
|
from pillar.api.utils.algolia import algolia_index_node_delete
|
||||||
@@ -26,6 +28,11 @@ def only_for_node_type_decorator(required_node_type_name):
|
|||||||
|
|
||||||
If the node type is not of the required node type, returns None,
|
If the node type is not of the required node type, returns None,
|
||||||
otherwise calls the wrapped function.
|
otherwise calls the wrapped function.
|
||||||
|
|
||||||
|
>>> deco = only_for_node_type_decorator('comment')
|
||||||
|
>>> @deco
|
||||||
|
... def handle_comment(node): pass
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def only_for_node_type(wrapped):
|
def only_for_node_type(wrapped):
|
||||||
@@ -35,6 +42,7 @@ def only_for_node_type_decorator(required_node_type_name):
|
|||||||
return
|
return
|
||||||
|
|
||||||
return wrapped(node, *args, **kwargs)
|
return wrapped(node, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
|
only_for_node_type.__doc__ = "Decorator, immediately returns when " \
|
||||||
@@ -415,8 +423,31 @@ def after_deleting_node(item):
|
|||||||
item.get('_id'), ex)
|
item.get('_id'), ex)
|
||||||
|
|
||||||
|
|
||||||
def setup_app(app, url_prefix):
|
only_for_comments = only_for_node_type_decorator('comment')
|
||||||
|
|
||||||
|
|
||||||
|
@only_for_comments
|
||||||
|
def convert_markdown(node, original=None):
|
||||||
|
"""Converts comments from Markdown to HTML.
|
||||||
|
|
||||||
|
Always does this on save, even when the original Markdown hasn't changed,
|
||||||
|
because our Markdown -> HTML conversion rules might have.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = node['properties']['content']
|
||||||
|
except KeyError:
|
||||||
|
node['properties']['content_html'] = ''
|
||||||
|
else:
|
||||||
|
node['properties']['content_html'] = pillar.markdown.markdown(content)
|
||||||
|
|
||||||
|
|
||||||
|
def nodes_convert_markdown(nodes):
|
||||||
|
for node in nodes:
|
||||||
|
convert_markdown(node)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_app(app, url_prefix):
|
||||||
from . import patch
|
from . import patch
|
||||||
patch.setup_app(app, url_prefix=url_prefix)
|
patch.setup_app(app, url_prefix=url_prefix)
|
||||||
|
|
||||||
@@ -427,6 +458,7 @@ def setup_app(app, url_prefix):
|
|||||||
app.on_fetched_resource_nodes += resource_parse_attachments
|
app.on_fetched_resource_nodes += resource_parse_attachments
|
||||||
|
|
||||||
app.on_replace_nodes += before_replacing_node
|
app.on_replace_nodes += before_replacing_node
|
||||||
|
app.on_replace_nodes += convert_markdown
|
||||||
app.on_replace_nodes += deduct_content_type
|
app.on_replace_nodes += deduct_content_type
|
||||||
app.on_replace_nodes += node_set_default_picture
|
app.on_replace_nodes += node_set_default_picture
|
||||||
app.on_replaced_nodes += after_replacing_node
|
app.on_replaced_nodes += after_replacing_node
|
||||||
@@ -434,6 +466,7 @@ def setup_app(app, url_prefix):
|
|||||||
app.on_insert_nodes += before_inserting_nodes
|
app.on_insert_nodes += before_inserting_nodes
|
||||||
app.on_insert_nodes += nodes_deduct_content_type
|
app.on_insert_nodes += nodes_deduct_content_type
|
||||||
app.on_insert_nodes += nodes_set_default_picture
|
app.on_insert_nodes += nodes_set_default_picture
|
||||||
|
app.on_insert_nodes += nodes_convert_markdown
|
||||||
app.on_inserted_nodes += after_inserting_nodes
|
app.on_inserted_nodes += after_inserting_nodes
|
||||||
|
|
||||||
app.on_deleted_item_nodes += after_deleting_node
|
app.on_deleted_item_nodes += after_deleting_node
|
||||||
|
125
pillar/cli.py
125
pillar/cli.py
@@ -5,9 +5,12 @@ Run commands with 'flask <command>'
|
|||||||
|
|
||||||
from __future__ import print_function, division
|
from __future__ import print_function, division
|
||||||
|
|
||||||
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from bson.objectid import ObjectId, InvalidId
|
from bson.objectid import ObjectId, InvalidId
|
||||||
|
from eve.methods.put import put_internal
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask_script import Manager
|
from flask_script import Manager
|
||||||
|
|
||||||
@@ -502,3 +505,125 @@ def move_group_node_project(node_uuid, dest_proj_url, force=False, skip_gcs=Fals
|
|||||||
mover.change_project(node, dest_proj)
|
mover.change_project(node, dest_proj)
|
||||||
|
|
||||||
log.info('Done moving.')
|
log.info('Done moving.')
|
||||||
|
|
||||||
|
|
||||||
|
@manager.command
|
||||||
|
@manager.option('-p', '--project', dest='proj_url', nargs='?',
|
||||||
|
help='Project URL')
|
||||||
|
@manager.option('-a', '--all', dest='all_projects', action='store_true', default=False,
|
||||||
|
help='Replace on all projects.')
|
||||||
|
def replace_pillar_node_type_schemas(proj_url=None, all_projects=False):
|
||||||
|
"""Replaces the project's node type schemas with the standard Pillar ones.
|
||||||
|
|
||||||
|
Non-standard node types are left alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if bool(proj_url) == all_projects:
|
||||||
|
log.error('Use either --project or --all.')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
from pillar.api.utils.authentication import force_cli_user
|
||||||
|
force_cli_user()
|
||||||
|
|
||||||
|
from pillar.api.node_types.asset import node_type_asset
|
||||||
|
from pillar.api.node_types.blog import node_type_blog
|
||||||
|
from pillar.api.node_types.comment import node_type_comment
|
||||||
|
from pillar.api.node_types.group import node_type_group
|
||||||
|
from pillar.api.node_types.group_hdri import node_type_group_hdri
|
||||||
|
from pillar.api.node_types.group_texture import node_type_group_texture
|
||||||
|
from pillar.api.node_types.hdri import node_type_hdri
|
||||||
|
from pillar.api.node_types.page import node_type_page
|
||||||
|
from pillar.api.node_types.post import node_type_post
|
||||||
|
from pillar.api.node_types.storage import node_type_storage
|
||||||
|
from pillar.api.node_types.text import node_type_text
|
||||||
|
from pillar.api.node_types.texture import node_type_texture
|
||||||
|
from pillar.api.utils import remove_private_keys
|
||||||
|
|
||||||
|
node_types = [node_type_asset, node_type_blog, node_type_comment, node_type_group,
|
||||||
|
node_type_group_hdri, node_type_group_texture, node_type_hdri, node_type_page,
|
||||||
|
node_type_post, node_type_storage, node_type_text, node_type_texture]
|
||||||
|
name_to_nt = {nt['name']: nt for nt in node_types}
|
||||||
|
|
||||||
|
projects_collection = current_app.db()['projects']
|
||||||
|
|
||||||
|
def handle_project(project):
|
||||||
|
log.info('Handling project %s', project['url'])
|
||||||
|
|
||||||
|
for proj_nt in project['node_types']:
|
||||||
|
nt_name = proj_nt['name']
|
||||||
|
try:
|
||||||
|
pillar_nt = name_to_nt[nt_name]
|
||||||
|
except KeyError:
|
||||||
|
log.info(' - skipping non-standard node type "%s"', nt_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.info(' - replacing schema on node type "%s"', nt_name)
|
||||||
|
|
||||||
|
# This leaves node type keys intact that aren't in Pillar's node_type_xxx definitions,
|
||||||
|
# such as permissions.
|
||||||
|
proj_nt.update(copy.deepcopy(pillar_nt))
|
||||||
|
|
||||||
|
# Use Eve to PUT, so we have schema checking.
|
||||||
|
db_proj = remove_private_keys(project)
|
||||||
|
r, _, _, status = put_internal('projects', db_proj, _id=project['_id'])
|
||||||
|
if status != 200:
|
||||||
|
log.error('Error %i storing altered project %s: %s', status, project['_id'], r)
|
||||||
|
return 4
|
||||||
|
log.info('Project saved succesfully.')
|
||||||
|
|
||||||
|
if all_projects:
|
||||||
|
for project in projects_collection.find():
|
||||||
|
handle_project(project)
|
||||||
|
return
|
||||||
|
|
||||||
|
project = projects_collection.find_one({'url': proj_url})
|
||||||
|
if not project:
|
||||||
|
log.error('Project url=%s not found', proj_url)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
handle_project(project)
|
||||||
|
|
||||||
|
|
||||||
|
@manager.command
|
||||||
|
def remarkdown_comments():
|
||||||
|
"""Retranslates all Markdown to HTML for all comment nodes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pillar.api.nodes import convert_markdown
|
||||||
|
from pprint import pformat
|
||||||
|
|
||||||
|
nodes_collection = current_app.db()['nodes']
|
||||||
|
comments = nodes_collection.find({'node_type': 'comment'},
|
||||||
|
projection={'properties.content': 1,
|
||||||
|
'node_type': 1})
|
||||||
|
|
||||||
|
updated = identical = skipped = errors = 0
|
||||||
|
for node in comments:
|
||||||
|
convert_markdown(node)
|
||||||
|
node_id = node['_id']
|
||||||
|
|
||||||
|
try:
|
||||||
|
content_html = node['properties']['content_html']
|
||||||
|
except KeyError:
|
||||||
|
log.warning('Node %s has no content_html', node_id)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = nodes_collection.update_one(
|
||||||
|
{'_id': node_id},
|
||||||
|
{'$set': {'properties.content_html': content_html}}
|
||||||
|
)
|
||||||
|
if result.matched_count != 1:
|
||||||
|
log.error('Unable to update node %s', node_id)
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if result.modified_count:
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
identical += 1
|
||||||
|
|
||||||
|
log.info('updated : %i', updated)
|
||||||
|
log.info('identical: %i', identical)
|
||||||
|
log.info('skipped : %i', skipped)
|
||||||
|
log.info('errors : %i', errors)
|
||||||
|
44
pillar/markdown.py
Normal file
44
pillar/markdown.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Bleached Markdown functionality.
|
||||||
|
|
||||||
|
This is for user-generated stuff, like comments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
import markdown as _markdown
|
||||||
|
|
||||||
|
ALLOWED_TAGS = [
|
||||||
|
'a',
|
||||||
|
'abbr',
|
||||||
|
'acronym',
|
||||||
|
'b', 'strong',
|
||||||
|
'i', 'em',
|
||||||
|
'blockquote',
|
||||||
|
'code',
|
||||||
|
'li', 'ol', 'ul',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'p',
|
||||||
|
'img',
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES = {
|
||||||
|
'a': ['href', 'title', 'target'],
|
||||||
|
'abbr': ['title'],
|
||||||
|
'acronym': ['title'],
|
||||||
|
'img': ['src', 'alt', 'width', 'height', 'title'],
|
||||||
|
'*': ['style'],
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_STYLES = [
|
||||||
|
'color', 'font-weight', 'background-color',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def markdown(s):
|
||||||
|
tainted_html = _markdown.markdown(s)
|
||||||
|
safe_html = bleach.clean(tainted_html,
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
|
styles=ALLOWED_STYLES)
|
||||||
|
return safe_html
|
@@ -3,9 +3,12 @@
|
|||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
|
||||||
import jinja2.filters
|
import jinja2.filters
|
||||||
|
import jinja2.utils
|
||||||
|
|
||||||
|
import pillar.api.utils
|
||||||
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
|
||||||
|
import pillar.markdown
|
||||||
|
|
||||||
|
|
||||||
def format_pretty_date(d):
|
def format_pretty_date(d):
|
||||||
@@ -37,9 +40,64 @@ def do_hide_none(s):
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Source: Django, django/template/defaultfilters.py
|
||||||
|
def do_pluralize(value, arg='s'):
|
||||||
|
"""
|
||||||
|
Returns a plural suffix if the value is not 1. By default, 's' is used as
|
||||||
|
the suffix:
|
||||||
|
|
||||||
|
* If value is 0, vote{{ value|pluralize }} displays "0 votes".
|
||||||
|
* If value is 1, vote{{ value|pluralize }} displays "1 vote".
|
||||||
|
* If value is 2, vote{{ value|pluralize }} displays "2 votes".
|
||||||
|
|
||||||
|
If an argument is provided, that string is used instead:
|
||||||
|
|
||||||
|
* If value is 0, class{{ value|pluralize:"es" }} displays "0 classes".
|
||||||
|
* If value is 1, class{{ value|pluralize:"es" }} displays "1 class".
|
||||||
|
* If value is 2, class{{ value|pluralize:"es" }} displays "2 classes".
|
||||||
|
|
||||||
|
If the provided argument contains a comma, the text before the comma is
|
||||||
|
used for the singular case and the text after the comma is used for the
|
||||||
|
plural case:
|
||||||
|
|
||||||
|
* If value is 0, cand{{ value|pluralize:"y,ies" }} displays "0 candies".
|
||||||
|
* If value is 1, cand{{ value|pluralize:"y,ies" }} displays "1 candy".
|
||||||
|
* If value is 2, cand{{ value|pluralize:"y,ies" }} displays "2 candies".
|
||||||
|
"""
|
||||||
|
|
||||||
|
if ',' not in arg:
|
||||||
|
arg = ',' + arg
|
||||||
|
bits = arg.split(',')
|
||||||
|
if len(bits) > 2:
|
||||||
|
return ''
|
||||||
|
singular_suffix, plural_suffix = bits[:2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if float(value) != 1:
|
||||||
|
return plural_suffix
|
||||||
|
except ValueError: # Invalid string that's not a number.
|
||||||
|
pass
|
||||||
|
except TypeError: # Value isn't a string or a number; maybe it's a list?
|
||||||
|
try:
|
||||||
|
if len(value) != 1:
|
||||||
|
return plural_suffix
|
||||||
|
except TypeError: # len() of unsized object.
|
||||||
|
pass
|
||||||
|
return singular_suffix
|
||||||
|
|
||||||
|
|
||||||
|
def do_markdown(s):
|
||||||
|
# FIXME: get rid of this filter altogether and cache HTML of comments.
|
||||||
|
safe_html = pillar.markdown.markdown(s)
|
||||||
|
return jinja2.utils.Markup(safe_html)
|
||||||
|
|
||||||
|
|
||||||
def setup_jinja_env(jinja_env):
|
def setup_jinja_env(jinja_env):
|
||||||
jinja_env.filters['pretty_date'] = format_pretty_date
|
jinja_env.filters['pretty_date'] = format_pretty_date
|
||||||
jinja_env.filters['pretty_date_time'] = format_pretty_date_time
|
jinja_env.filters['pretty_date_time'] = format_pretty_date_time
|
||||||
jinja_env.filters['undertitle'] = format_undertitle
|
jinja_env.filters['undertitle'] = format_undertitle
|
||||||
jinja_env.filters['hide_none'] = do_hide_none
|
jinja_env.filters['hide_none'] = do_hide_none
|
||||||
|
jinja_env.filters['pluralize'] = do_pluralize
|
||||||
|
jinja_env.filters['gravatar'] = pillar.api.utils.gravatar
|
||||||
|
jinja_env.filters['markdown'] = do_markdown
|
||||||
jinja_env.globals['url_for_node'] = url_for_node
|
jinja_env.globals['url_for_node'] = url_for_node
|
||||||
|
@@ -1,4 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import flask
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
@@ -7,6 +10,8 @@ from flask_login import login_required, current_user
|
|||||||
from pillarsdk import Node
|
from pillarsdk import Node
|
||||||
from pillarsdk import Project
|
from pillarsdk import Project
|
||||||
import werkzeug.exceptions as wz_exceptions
|
import werkzeug.exceptions as wz_exceptions
|
||||||
|
|
||||||
|
from pillar.web import subquery
|
||||||
from pillar.web.nodes.routes import blueprint
|
from pillar.web.nodes.routes import blueprint
|
||||||
from pillar.web.utils import gravatar
|
from pillar.web.utils import gravatar
|
||||||
from pillar.web.utils import pretty_date
|
from pillar.web.utils import pretty_date
|
||||||
@@ -20,10 +25,22 @@ log = logging.getLogger(__name__)
|
|||||||
def comments_create():
|
def comments_create():
|
||||||
content = request.form['content']
|
content = request.form['content']
|
||||||
parent_id = request.form.get('parent_id')
|
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()
|
api = system_util.pillar_api()
|
||||||
parent_node = Node.find(parent_id, api=api)
|
parent_node = Node.find(parent_id, api=api)
|
||||||
|
if not parent_node:
|
||||||
|
log.warning('Unable to create comment for user %s, parent node %r not found',
|
||||||
|
current_user.objectid, parent_id)
|
||||||
|
raise wz_exceptions.UnprocessableEntity()
|
||||||
|
|
||||||
node_asset_props = dict(
|
log.warning('Creating comment for user %s on parent node %r',
|
||||||
|
current_user.objectid, parent_id)
|
||||||
|
|
||||||
|
comment_props = dict(
|
||||||
project=parent_node.project,
|
project=parent_node.project,
|
||||||
name='Comment',
|
name='Comment',
|
||||||
user=current_user.objectid,
|
user=current_user.objectid,
|
||||||
@@ -36,20 +53,18 @@ def comments_create():
|
|||||||
rating_negative=0))
|
rating_negative=0))
|
||||||
|
|
||||||
if parent_id:
|
if parent_id:
|
||||||
node_asset_props['parent'] = parent_id
|
comment_props['parent'] = parent_id
|
||||||
|
|
||||||
# Get the parent node and check if it's a comment. In which case we flag
|
# Get the parent node and check if it's a comment. In which case we flag
|
||||||
# the current comment as a reply.
|
# the current comment as a reply.
|
||||||
parent_node = Node.find(parent_id, api=api)
|
parent_node = Node.find(parent_id, api=api)
|
||||||
if parent_node.node_type == 'comment':
|
if parent_node.node_type == 'comment':
|
||||||
node_asset_props['properties']['is_reply'] = True
|
comment_props['properties']['is_reply'] = True
|
||||||
|
|
||||||
node_asset = Node(node_asset_props)
|
comment = Node(comment_props)
|
||||||
node_asset.create(api=api)
|
comment.create(api=api)
|
||||||
|
|
||||||
return jsonify(
|
return jsonify({'node_id': comment._id}), 201
|
||||||
asset_id=node_asset._id,
|
|
||||||
content=node_asset.properties.content)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route('/comments/<string(length=24):comment_id>', methods=['POST'])
|
@blueprint.route('/comments/<string(length=24):comment_id>', methods=['POST'])
|
||||||
@@ -119,6 +134,8 @@ def format_comment(comment, is_reply=False, is_team=False, replies=None):
|
|||||||
|
|
||||||
@blueprint.route("/comments/")
|
@blueprint.route("/comments/")
|
||||||
def comments_index():
|
def comments_index():
|
||||||
|
warnings.warn('comments_index() is deprecated in favour of comments_for_node()')
|
||||||
|
|
||||||
parent_id = request.args.get('parent_id')
|
parent_id = request.args.get('parent_id')
|
||||||
# Get data only if we format it
|
# Get data only if we format it
|
||||||
api = system_util.pillar_api()
|
api = system_util.pillar_api()
|
||||||
@@ -152,6 +169,50 @@ def comments_index():
|
|||||||
return return_content
|
return return_content
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route('/<string(length=24):node_id>/comments')
|
||||||
|
def comments_for_node(node_id):
|
||||||
|
"""Shows the comments attached to the given node."""
|
||||||
|
|
||||||
|
api = system_util.pillar_api()
|
||||||
|
|
||||||
|
node = Node.find(node_id, api=api)
|
||||||
|
project = Project({'_id': node.project})
|
||||||
|
can_post_comments = project.node_type_has_method('comment', 'POST', api=api)
|
||||||
|
|
||||||
|
# Query for all children, i.e. comments on the node.
|
||||||
|
comments = Node.all({
|
||||||
|
'where': {'node_type': 'comment', 'parent': node_id},
|
||||||
|
}, api=api)
|
||||||
|
|
||||||
|
def enrich(some_comment):
|
||||||
|
some_comment['_user'] = subquery.get_user_info(some_comment['user'])
|
||||||
|
some_comment['_is_own'] = some_comment['user'] == current_user.objectid
|
||||||
|
some_comment['_current_user_rating'] = None # tri-state boolean
|
||||||
|
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
for rating in comment.properties.ratings or ():
|
||||||
|
if rating.user != current_user.objectid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
some_comment['_current_user_rating'] = rating.is_positive
|
||||||
|
|
||||||
|
for comment in comments['_items']:
|
||||||
|
# Query for all grandchildren, i.e. replies to comments on the node.
|
||||||
|
comment['_replies'] = Node.all({
|
||||||
|
'where': {'node_type': 'comment', 'parent': comment['_id']},
|
||||||
|
}, api=api)
|
||||||
|
|
||||||
|
enrich(comment)
|
||||||
|
for reply in comment['_replies']['_items']:
|
||||||
|
enrich(reply)
|
||||||
|
|
||||||
|
# Data will be requested via javascript
|
||||||
|
return render_template('nodes/custom/comment/list_embed.html',
|
||||||
|
node_id=node_id,
|
||||||
|
comments=comments,
|
||||||
|
can_post_comments=can_post_comments)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/comments/<comment_id>/rate/<operation>", methods=['POST'])
|
@blueprint.route("/comments/<comment_id>/rate/<operation>", methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def comments_rate(comment_id, operation):
|
def comments_rate(comment_id, operation):
|
||||||
|
@@ -5,6 +5,7 @@ algoliasearch==1.8.0
|
|||||||
bcrypt==2.0.0
|
bcrypt==2.0.0
|
||||||
blinker==1.4
|
blinker==1.4
|
||||||
bugsnag==2.3.1
|
bugsnag==2.3.1
|
||||||
|
bleach==1.4.3
|
||||||
Cerberus==0.9.2
|
Cerberus==0.9.2
|
||||||
Eve==0.6.3
|
Eve==0.6.3
|
||||||
Events==0.2.1
|
Events==0.2.1
|
||||||
@@ -19,6 +20,7 @@ google-apitools==0.4.11
|
|||||||
httplib2==0.9.2
|
httplib2==0.9.2
|
||||||
idna==2.0
|
idna==2.0
|
||||||
MarkupSafe==0.23
|
MarkupSafe==0.23
|
||||||
|
markdown==2.6.7
|
||||||
ndg-httpsclient==0.4.0
|
ndg-httpsclient==0.4.0
|
||||||
Pillow==2.8.1
|
Pillow==2.8.1
|
||||||
pycparser==2.14
|
pycparser==2.14
|
||||||
@@ -48,6 +50,7 @@ cookies==2.2.1
|
|||||||
cryptography==1.3.1
|
cryptography==1.3.1
|
||||||
enum34==1.1.3
|
enum34==1.1.3
|
||||||
funcsigs==1.0.1
|
funcsigs==1.0.1
|
||||||
|
html5lib==0.9999999
|
||||||
googleapis-common-protos==1.1.0
|
googleapis-common-protos==1.1.0
|
||||||
ipaddress==1.0.16
|
ipaddress==1.0.16
|
||||||
itsdangerous==0.24
|
itsdangerous==0.24
|
||||||
|
@@ -4,24 +4,29 @@ $(document).on('click','body .comment-action-reply',function(e){
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// container of the comment we are replying to
|
// container of the comment we are replying to
|
||||||
var parentDiv = $(this).parent().parent();
|
var parentDiv = $(this).closest('.comment-container');
|
||||||
|
|
||||||
// container of the first-level comment in the thread
|
// container of the first-level comment in the thread
|
||||||
var parentDivFirst = $(this).parent().parent().prevAll('.is-first:first');
|
var parentDivFirst = parentDiv.prevAll('.is-first:first');
|
||||||
|
|
||||||
// Get the id of the comment
|
// Get the id of the comment
|
||||||
if (parentDiv.hasClass('is-reply')) {
|
if (parentDiv.hasClass('is-reply')) {
|
||||||
parentNodeId = parentDivFirst.data('node_id');
|
parentNodeId = parentDivFirst.data('node-id');
|
||||||
} else {
|
} else {
|
||||||
parentNodeId = parentDiv.data('node_id');
|
parentNodeId = parentDiv.data('node-id');
|
||||||
|
}
|
||||||
|
if (!parentNodeId) {
|
||||||
|
if (console) console.log('No parent ID found on ', parentDiv.toArray(), parentDivFirst.toArray());
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the textarea and set its parent_id data
|
// Get the textarea and set its parent_id data
|
||||||
var commentField = document.getElementById('comment_field');
|
var commentField = document.getElementById('comment_field');
|
||||||
commentField.setAttribute('data-parent_id', parentNodeId);
|
commentField.dataset.parentId = parentNodeId;
|
||||||
|
|
||||||
// Start the comment field with @authorname:
|
// Start the comment field with @authorname:
|
||||||
var replyAuthor = $(this).parent().parent().find('.comment-author:first span').html();
|
var replyAuthor = parentDiv.find('.comment-author:first span').html();
|
||||||
$(commentField).val("**@" + replyAuthor.slice(1, -1) + ":** ");
|
$(commentField).val("**@" + replyAuthor.slice(1, -1) + ":** ");
|
||||||
|
|
||||||
// Add class for styling
|
// Add class for styling
|
||||||
@@ -107,3 +112,179 @@ $(document).on('click','body .comment-action-rating',function(e){
|
|||||||
$this.siblings('.comment-rating-value').text(rating);
|
$this.siblings('.comment-rating-value').text(rating);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a comment, returns a promise object.
|
||||||
|
*/
|
||||||
|
function loadComment(comment_id, projection)
|
||||||
|
{
|
||||||
|
if (typeof comment_id === 'undefined') {
|
||||||
|
console.log('Error, loadComment(', comment_id, ', ', projection, ') called.');
|
||||||
|
return $.Deferred().reject();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add required projections for the permission system to work.
|
||||||
|
projection.node_type = 1;
|
||||||
|
projection.project = 1;
|
||||||
|
|
||||||
|
var url = '/api/nodes/' + comment_id;
|
||||||
|
return $.get({
|
||||||
|
url: url,
|
||||||
|
data: {projection: projection},
|
||||||
|
cache: false, // user should be ensured the latest comment to edit.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function loadComments(commentsUrl)
|
||||||
|
{
|
||||||
|
return $.get(commentsUrl)
|
||||||
|
.done(function(dataHtml) {
|
||||||
|
// Update the DOM injecting the generate HTML into the page
|
||||||
|
$('#comments-container').html(dataHtml);
|
||||||
|
})
|
||||||
|
.fail(function(xhr) {
|
||||||
|
statusBarSet('error', "Couldn't load comments. Error: " + xhr.responseText, 'pi-attention', 5000);
|
||||||
|
$('#comments-container').html('<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('button-field-error');
|
||||||
|
$textarea.addClass('field-error');
|
||||||
|
$button.html(msg);
|
||||||
|
|
||||||
|
setTimeout(function(){
|
||||||
|
$button.html('Post Comment');
|
||||||
|
$button.removeClass('button-field-error');
|
||||||
|
$textarea.removeClass('field-error');
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an error in the "edit comment" button.
|
||||||
|
*/
|
||||||
|
function show_comment_edit_button_error($button, msg) {
|
||||||
|
var $textarea = $('#comment_field');
|
||||||
|
|
||||||
|
$button.addClass('error');
|
||||||
|
$textarea.addClass('field-error');
|
||||||
|
$button.html(msg);
|
||||||
|
|
||||||
|
setTimeout(function(){
|
||||||
|
$button.html('<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return UI to normal, when cancelling or saving.
|
||||||
|
*
|
||||||
|
* clicked_item: save/cancel button.
|
||||||
|
*
|
||||||
|
* Returns a promise on the comment loading.
|
||||||
|
*/
|
||||||
|
function commentEditCancel(clicked_item) {
|
||||||
|
var comment_container = $(clicked_item).closest('.comment-container');
|
||||||
|
var comment_id = comment_container.data('node-id');
|
||||||
|
|
||||||
|
return loadComment(comment_id, {'properties.content': 1})
|
||||||
|
.done(function(data) {
|
||||||
|
var comment_raw = data['properties']['content'];
|
||||||
|
var comment_html = convert(comment_raw);
|
||||||
|
|
||||||
|
comment_mode(clicked_item, 'view');
|
||||||
|
comment_container.find('.comment-content')
|
||||||
|
.removeClass('editing')
|
||||||
|
.html(comment_html);
|
||||||
|
comment_container.find('.comment-content-preview').html('').hide();
|
||||||
|
})
|
||||||
|
.fail(function(data) {
|
||||||
|
if (console) console.log('Error fetching comment: ', xhr);
|
||||||
|
statusBarSet('error', 'Error canceling.', 'pi-warning');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function save_comment(is_new_comment, $commentContainer)
|
||||||
|
{
|
||||||
|
var promise = $.Deferred();
|
||||||
|
var commentField;
|
||||||
|
var commentId;
|
||||||
|
var parent_id;
|
||||||
|
|
||||||
|
// Get data from HTML, and validate it.
|
||||||
|
if (is_new_comment)
|
||||||
|
commentField = $('#comment_field');
|
||||||
|
else {
|
||||||
|
commentField = $commentContainer.find('textarea');
|
||||||
|
commentId = $commentContainer.data('node-id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commentField.length)
|
||||||
|
return promise.reject("Unable to find comment field.");
|
||||||
|
|
||||||
|
if (is_new_comment) {
|
||||||
|
parent_id = commentField.data('parent-id');
|
||||||
|
if (!parent_id) {
|
||||||
|
if (console) console.log("No parent ID found in comment field data.");
|
||||||
|
return promise.reject("No parent ID!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the comment itself.
|
||||||
|
var comment = commentField.val();
|
||||||
|
if (comment.length < 5) {
|
||||||
|
if (comment.length == 0) promise.reject("Say something...");
|
||||||
|
else promise.reject("Minimum 5 characters.");
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify callers of the fact that client-side validation has passed.
|
||||||
|
promise.notify();
|
||||||
|
|
||||||
|
// Actually post the comment.
|
||||||
|
if (is_new_comment) {
|
||||||
|
$.post('/nodes/comments/create',
|
||||||
|
{'content': comment, 'parent_id': parent_id})
|
||||||
|
.fail(promise.reject)
|
||||||
|
.done(function(data) { promise.resolve(data.node_id, comment); });
|
||||||
|
} else {
|
||||||
|
$.post('/nodes/comments/' + commentId,
|
||||||
|
{'content': comment})
|
||||||
|
.fail(promise.reject)
|
||||||
|
.done(function(data) { promise.resolve(commentId, comment); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
42
src/scripts/tutti/6_jquery_extensions.js
Normal file
42
src/scripts/tutti/6_jquery_extensions.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
(function ( $ ) {
|
||||||
|
$.fn.flashOnce = function() {
|
||||||
|
var target = this;
|
||||||
|
this
|
||||||
|
.addClass('flash-on')
|
||||||
|
.delay(1000) // this delay is linked to the transition in the flash-on CSS class.
|
||||||
|
.queue(function() {
|
||||||
|
target
|
||||||
|
.removeClass('flash-on')
|
||||||
|
.addClass('flash-off')
|
||||||
|
.dequeue()
|
||||||
|
;})
|
||||||
|
.delay(1000) // this delay is just to clean up the flash-X classes.
|
||||||
|
.queue(function() {
|
||||||
|
target
|
||||||
|
.removeClass('flash-on flash-off')
|
||||||
|
.dequeue()
|
||||||
|
;})
|
||||||
|
;
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fades out the element, then erases its contents and shows the now-empty element again.
|
||||||
|
*/
|
||||||
|
$.fn.fadeOutAndClear = function(fade_speed) {
|
||||||
|
var target = this;
|
||||||
|
this
|
||||||
|
.fadeOut(fade_speed, function() {
|
||||||
|
target
|
||||||
|
.html('')
|
||||||
|
.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$.fn.scrollHere = function(scroll_duration_msec) {
|
||||||
|
$('html, body').animate({
|
||||||
|
scrollTop: this.offset().top
|
||||||
|
}, scroll_duration_msec);
|
||||||
|
}
|
||||||
|
|
||||||
|
}(jQuery));
|
@@ -577,3 +577,19 @@
|
|||||||
display: block
|
display: block
|
||||||
max-width: 100%
|
max-width: 100%
|
||||||
height: auto
|
height: auto
|
||||||
|
|
||||||
|
|
||||||
|
.flash-on
|
||||||
|
background-color: lighten($color-success, 50%) !important
|
||||||
|
border-color: lighten($color-success, 40%) !important
|
||||||
|
color: $color-success !important
|
||||||
|
text-shadow: 1px 1px 0 white
|
||||||
|
transition: all .1s ease-in
|
||||||
|
img
|
||||||
|
transition: all .1s ease-in
|
||||||
|
opacity: .8
|
||||||
|
|
||||||
|
.flash-off
|
||||||
|
transition: all 1s ease-out
|
||||||
|
img
|
||||||
|
transition: all 1s ease-out
|
||||||
|
@@ -53,26 +53,8 @@ script(type="text/javascript").
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadComments(){
|
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node._id) }}";
|
||||||
var commentsUrl = "{{ url_for('nodes.comments_index', parent_id=node._id) }}";
|
loadComments(commentsUrl);
|
||||||
|
|
||||||
$.get(commentsUrl, function(dataHtml) {
|
|
||||||
})
|
|
||||||
.done(function(dataHtml){
|
|
||||||
// Update the DOM injecting the generate HTML into the page
|
|
||||||
$('#comments-container').replaceWith(dataHtml);
|
|
||||||
})
|
|
||||||
.fail(function(e, data){
|
|
||||||
statusBarSet('error', 'Couldn\'t load comments. Error: ' + data.errorThrown, 'pi-attention', 5000);
|
|
||||||
$('#comments-container').html('<a id="comments-reload"><i class="pi-refresh"></i> Reload comments</a>');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadComments();
|
|
||||||
|
|
||||||
$('body').on('click', '#comments-reload', function(){
|
|
||||||
loadComments();
|
|
||||||
});
|
|
||||||
|
|
||||||
{% if node.has_method('PUT') %}
|
{% if node.has_method('PUT') %}
|
||||||
$('.project-mode-view').show();
|
$('.project-mode-view').show();
|
||||||
@@ -186,4 +168,3 @@ script(type="text/javascript").
|
|||||||
if (typeof $().tooltip != 'undefined'){
|
if (typeof $().tooltip != 'undefined'){
|
||||||
$('[data-toggle="tooltip"]').tooltip({'delay' : {'show': 1250, 'hide': 250}});
|
$('[data-toggle="tooltip"]').tooltip({'delay' : {'show': 1250, 'hide': 250}});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
46
src/templates/nodes/custom/comment/_macros.jade
Normal file
46
src/templates/nodes/custom/comment/_macros.jade
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
| {%- macro render_comment(comment, is_reply) -%}
|
||||||
|
.comment-container(
|
||||||
|
id="{{ comment._id }}",
|
||||||
|
data-node-id="{{ comment._id }}",
|
||||||
|
class="{% if is_reply %}is-reply{% else %}is-first{% endif %}")
|
||||||
|
|
||||||
|
.comment-header
|
||||||
|
.comment-avatar
|
||||||
|
img(src="{{ comment._user.email | gravatar }}")
|
||||||
|
|
||||||
|
.comment-author(class="{% if comment._is_own %}own{% endif %}")
|
||||||
|
| {{ comment._user.full_name }}
|
||||||
|
span.username ({{ comment._user.username }})
|
||||||
|
|
||||||
|
.comment-time {{ comment._created | pretty_date_time }} {% if comment._created != comment._updated %} (edited {{ comment._updated | pretty_date_time }}){% endif %}
|
||||||
|
|
||||||
|
.comment-content {{comment.properties.content_html | safe }}
|
||||||
|
| {% if comment._is_own %}
|
||||||
|
.comment-content-preview
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
|
.comment-meta
|
||||||
|
.comment-rating(
|
||||||
|
class="{% if comment._current_user_rating is not none %}rated{% if comment._current_user_rating %}positive{% endif %}{% endif %}")
|
||||||
|
.comment-rating-value(title="Number of likes") {{ rating }}
|
||||||
|
| {% if not comment._is_own %}
|
||||||
|
.comment-action-rating.up(title="Like comment")
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
|
.comment-action-reply(title="Reply to this comment")
|
||||||
|
span reply
|
||||||
|
| {% if comment._is_own %}
|
||||||
|
.comment-action-edit
|
||||||
|
span.edit_mode(title="Edit comment") edit
|
||||||
|
span.edit_save(title="Save comment")
|
||||||
|
i.pi-check
|
||||||
|
| save changes
|
||||||
|
span.edit_cancel(title="Cancel changes")
|
||||||
|
i.pi-cancel
|
||||||
|
| cancel
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
|
| {% for reply in comment['_replies']['_items'] %}
|
||||||
|
| {{ render_comment(reply, True) }}
|
||||||
|
| {% endfor %}
|
||||||
|
| {%- endmacro -%}
|
199
src/templates/nodes/custom/comment/list_embed.jade
Normal file
199
src/templates/nodes/custom/comment/list_embed.jade
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
| {% import 'nodes/custom/comment/_macros.html' as macros %}
|
||||||
|
#comments-container
|
||||||
|
a(name="comments")
|
||||||
|
|
||||||
|
section#comments-list
|
||||||
|
.comment-reply-container
|
||||||
|
| {% if can_post_comments %}
|
||||||
|
.comment-reply-avatar
|
||||||
|
img(src="{{ current_user.gravatar }}")
|
||||||
|
|
||||||
|
.comment-reply-form
|
||||||
|
|
||||||
|
.comment-reply-field
|
||||||
|
textarea(
|
||||||
|
id="comment_field",
|
||||||
|
data-parent-id="{{ node_id }}",
|
||||||
|
placeholder="Join the conversation...",)
|
||||||
|
|
||||||
|
.comment-reply-meta
|
||||||
|
.comment-details
|
||||||
|
.comment-rules
|
||||||
|
a(
|
||||||
|
title="Markdown Supported"
|
||||||
|
href="https://guides.github.com/features/mastering-markdown/")
|
||||||
|
i.pi-markdown
|
||||||
|
|
||||||
|
.comment-author
|
||||||
|
span.commenting-as commenting as
|
||||||
|
span.author-name {{ current_user.full_name }}
|
||||||
|
|
||||||
|
button.comment-action-cancel.btn.btn-outline(
|
||||||
|
type="button",
|
||||||
|
title="Cancel")
|
||||||
|
i.pi-cancel
|
||||||
|
button.comment-action-submit.btn.btn-outline(
|
||||||
|
id="comment_submit",
|
||||||
|
type="button",
|
||||||
|
title="Post Comment")
|
||||||
|
| Post Comment
|
||||||
|
span.hint (Ctrl+Enter)
|
||||||
|
|
||||||
|
.comment-reply-preview
|
||||||
|
|
||||||
|
| {% elif current_user.is_authenticated %}
|
||||||
|
|
||||||
|
| {# * User is authenticated, but has no 'POST' permission #}
|
||||||
|
.comment-reply-form
|
||||||
|
.comment-reply-field.sign-in
|
||||||
|
textarea(
|
||||||
|
disabled,
|
||||||
|
id="comment_field",
|
||||||
|
data-parent-id="{{ node_id }}",
|
||||||
|
placeholder="")
|
||||||
|
.sign-in
|
||||||
|
| Join the conversation! <a href="https://store.blender.org/product/membership/">Subscribe to Blender Cloud now.</a>
|
||||||
|
|
||||||
|
| {% else %}
|
||||||
|
| {# * User is not autenticated #}
|
||||||
|
.comment-reply-form
|
||||||
|
.comment-reply-field.sign-in
|
||||||
|
textarea(
|
||||||
|
disabled,
|
||||||
|
id="comment_field",
|
||||||
|
data-parent-id="{{ node_id }}",
|
||||||
|
placeholder="")
|
||||||
|
.sign-in
|
||||||
|
a(href="{{ url_for('users.login') }}") Log in
|
||||||
|
| to comment.
|
||||||
|
|
||||||
|
| {% endif %}
|
||||||
|
|
||||||
|
section#comments-list-header
|
||||||
|
#comments-list-title
|
||||||
|
| {% if comments['_meta']['total'] == 0 %}No{% else %}{{ comments['_meta']['total'] }}{% endif %} comment{{ comments['_meta']['total']|pluralize }}
|
||||||
|
#comments-list-items
|
||||||
|
| {% for comment in comments['_items'] %}
|
||||||
|
| {{ macros.render_comment(comment, False) }}
|
||||||
|
| {% endfor %}
|
||||||
|
| {% block comment_scripts %}
|
||||||
|
|
||||||
|
script.
|
||||||
|
/* Submit new comment */
|
||||||
|
$('.comment-action-submit').click(function(e){
|
||||||
|
var $button = $(this);
|
||||||
|
|
||||||
|
save_comment(true)
|
||||||
|
.progress(function() {
|
||||||
|
$button
|
||||||
|
.addClass('submitting')
|
||||||
|
.html('<i class="pi-spin spin"></i> Posting...');
|
||||||
|
})
|
||||||
|
.fail(function(xhr){
|
||||||
|
if (typeof xhr === 'string') {
|
||||||
|
show_comment_button_error(xhr);
|
||||||
|
} else {
|
||||||
|
// If it's not a string, we assume it's a jQuery XHR object.
|
||||||
|
if (console) console.log('Error saving comment:', xhr.responseText);
|
||||||
|
show_comment_button_error("Houston! Try again?");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.done(function(comment_node_id) {
|
||||||
|
var commentsUrl = "{{ url_for('nodes.comments_for_node', node_id=node_id) }}";
|
||||||
|
loadComments(commentsUrl)
|
||||||
|
.done(function() {
|
||||||
|
$('#' + comment_node_id).scrollHere();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* Edit comment */
|
||||||
|
|
||||||
|
// Markdown convert as we type in the textarea
|
||||||
|
$(document).on('keyup','body .comment-content textarea',function(e){
|
||||||
|
var $textarea = $(this);
|
||||||
|
var $container = $(this).parent();
|
||||||
|
var $preview = $container.next();
|
||||||
|
|
||||||
|
// TODO: communicate with back-end to do the conversion,
|
||||||
|
// rather than relying on our JS-converted Markdown.
|
||||||
|
$preview.html(convert($textarea.val()));
|
||||||
|
|
||||||
|
// While we are at it, style if empty
|
||||||
|
if (!$textarea.val()) {
|
||||||
|
$container.addClass('empty');
|
||||||
|
} else {
|
||||||
|
$container.removeClass('empty');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/* Enter edit mode */
|
||||||
|
$(document).on('click','body .comment-action-edit span.edit_mode',function(){
|
||||||
|
comment_mode(this, 'edit');
|
||||||
|
|
||||||
|
var parent_div = $(this).closest('.comment-container');
|
||||||
|
var comment_id = parent_div.data('node-id');
|
||||||
|
|
||||||
|
var comment_content = parent_div.find('.comment-content');
|
||||||
|
var height = comment_content.height();
|
||||||
|
|
||||||
|
loadComment(comment_id, {'properties.content': 1})
|
||||||
|
.done(function(data) {
|
||||||
|
var comment_raw = data['properties']['content'];
|
||||||
|
comment_content.html($('<textarea>').text(comment_raw));
|
||||||
|
comment_content
|
||||||
|
.addClass('editing')
|
||||||
|
.find('textarea')
|
||||||
|
.height(height + 30)
|
||||||
|
.focus()
|
||||||
|
.trigger('keyup');
|
||||||
|
comment_content.siblings('.comment-content-preview').show();
|
||||||
|
})
|
||||||
|
.fail(function(xhr) {
|
||||||
|
if (console) console.log('Error fetching comment: ', xhr);
|
||||||
|
statusBarSet('error', 'Error ' + xhr.status + ' entering edit mode.', 'pi-warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click','body .comment-action-edit span.edit_cancel',function(e){
|
||||||
|
commentEditCancel(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Save edited comment */
|
||||||
|
$(document).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) {
|
||||||
|
commentEditCancel($button)
|
||||||
|
.done(function() {
|
||||||
|
// TODO: reload just this comment's HTML from the back-end,
|
||||||
|
// rather than relying on our JS-converted Markdown.
|
||||||
|
$container.find('.comment-content').html(convert(comment));
|
||||||
|
$container.flashOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
$button
|
||||||
|
.html('<i class="pi-check"></i> save changes')
|
||||||
|
.removeClass('saving');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
| {% endblock %}
|
Reference in New Issue
Block a user