From 72c997173c4e6bda613e9e759de580ad8c45bf3f Mon Sep 17 00:00:00 2001 From: Shane <181157+stgn@users.noreply.github.com> Date: Sun, 5 Nov 2017 09:26:30 -0500 Subject: [PATCH] [Schema+config change] Comment editing (#396) * Comment editing * Optional time limit for comment editing --- config.example.py | 8 ++++ ...f7bf6d0e6bd_add_edited_time_to_comments.py | 30 ++++++++++++ nyaa/forms.py | 2 +- nyaa/models.py | 10 ++++ nyaa/static/css/main.css | 34 +++++++++++++- nyaa/static/js/main.js | 46 +++++++++++++++++++ nyaa/templates/view.html | 32 ++++++++++--- nyaa/views/torrents.py | 31 +++++++++++++ 8 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 migrations/versions/cf7bf6d0e6bd_add_edited_time_to_comments.py diff --git a/config.example.py b/config.example.py index 2d55d92..da87a9e 100644 --- a/config.example.py +++ b/config.example.py @@ -127,3 +127,11 @@ ENABLE_ELASTIC_SEARCH_HIGHLIGHT = False ES_MAX_SEARCH_RESULT = 1000 # ES index name generally (nyaa or sukebei) ES_INDEX_NAME = SITE_FLAVOR + +################ +## Commenting ## +################ + +# Time limit for editing a comment after it has been posted (seconds) +# Set to 0 to disable +EDITING_TIME_LIMIT = 0 diff --git a/migrations/versions/cf7bf6d0e6bd_add_edited_time_to_comments.py b/migrations/versions/cf7bf6d0e6bd_add_edited_time_to_comments.py new file mode 100644 index 0000000..11f7ccb --- /dev/null +++ b/migrations/versions/cf7bf6d0e6bd_add_edited_time_to_comments.py @@ -0,0 +1,30 @@ +"""Add edited_time to Comments + +Revision ID: cf7bf6d0e6bd +Revises: 500117641608 +Create Date: 2017-10-28 15:32:12.687378 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'cf7bf6d0e6bd' +down_revision = '500117641608' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('nyaa_comments', sa.Column('edited_time', sa.DateTime(), nullable=True)) + op.add_column('sukebei_comments', sa.Column('edited_time', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('sukebei_comments', 'edited_time') + op.drop_column('nyaa_comments', 'edited_time') + # ### end Alembic commands ### diff --git a/nyaa/forms.py b/nyaa/forms.py index f2c1de7..18a45ee 100644 --- a/nyaa/forms.py +++ b/nyaa/forms.py @@ -184,7 +184,7 @@ class CommentForm(FlaskForm): comment = TextAreaField('Make a comment', [ Length(min=3, max=1024, message='Comment must be at least %(min)d characters ' 'long and %(max)d at most.'), - DataRequired() + DataRequired(message='Comment must not be empty.') ]) diff --git a/nyaa/models.py b/nyaa/models.py index cc19114..c1dc61e 100644 --- a/nyaa/models.py +++ b/nyaa/models.py @@ -433,6 +433,7 @@ class CommentBase(DeclarativeHelperBase): return db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE')) created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow) + edited_time = db.Column(db.DateTime(timezone=False), onupdate=datetime.utcnow) text = db.Column(TextType(collation=COL_UTF8MB4_BIN), nullable=False) @declarative.declared_attr @@ -448,6 +449,15 @@ class CommentBase(DeclarativeHelperBase): ''' Returns a UTC POSIX timestamp, as seconds ''' return (self.created_time - UTC_EPOCH).total_seconds() + @property + def editable_until(self): + return self.created_utc_timestamp + config['EDITING_TIME_LIMIT'] + + @property + def editing_limit_exceeded(self): + limit = config['EDITING_TIME_LIMIT'] + return bool(limit and (datetime.utcnow() - self.created_time).total_seconds() >= limit) + class UserLevelType(IntEnum): REGULAR = 0 diff --git a/nyaa/static/css/main.css b/nyaa/static/css/main.css index 2136a55..9d4eefc 100644 --- a/nyaa/static/css/main.css +++ b/nyaa/static/css/main.css @@ -271,6 +271,10 @@ a.text-purple:hover, a.text-purple:active, a.text-purple:focus { color: #a760e0; } +.comment-details { + margin-bottom: 10px; +} + .comment-content { overflow-wrap: break-word; } @@ -286,11 +290,39 @@ a.text-purple:hover, a.text-purple:active, a.text-purple:focus { margin-bottom: 10px; } -.delete-comment-form { +.comment-actions { position: relative; float: right; } +.delete-comment-form { + display: inline-block; +} + +.edit-comment-box { + display: none; +} + +.is-editing .edit-comment-box { + display: block; +} + +.is-editing .comment-content { + display: none; +} + +.edit-waiting { + float: right; + width: 20px; + height: 20px; + border: 2px solid; + border-color: gray transparent; + border-radius: 100%; + position: relative; + top: 4px; + animation: fa-spin 1s infinite linear; +} + #comment { height: 8em; } diff --git a/nyaa/static/js/main.js b/nyaa/static/js/main.js index 88bdf4e..90db9b7 100644 --- a/nyaa/static/js/main.js +++ b/nyaa/static/js/main.js @@ -74,6 +74,52 @@ $(document).ready(function() { $(this).blur().children('i').toggleClass('fa-folder-open fa-folder'); $(this).next().stop().slideToggle(250); }); + + $('.edit-comment').click(function(e) { + e.preventDefault(); + $(this).closest('.comment').toggleClass('is-editing'); + }); + + $('[data-until]').each(function() { + var $this = $(this), + text = $(this).text(), + until = $this.data('until'); + + var displayTimeRemaining = function() { + var diff = Math.max(0, until - (Date.now() / 1000) | 0), + min = Math.floor(diff / 60), + sec = diff % 60; + $this.text(text + ' (' + min + ':' + ('00' + sec).slice(-2) + ')'); + }; + + displayTimeRemaining(); + setInterval(displayTimeRemaining, 1000); + }); + + $('.edit-comment-box').submit(function(e) { + e.preventDefault(); + + var $this = $(this), + $submitButton = $this.find('[type=submit]').attr('disabled', 'disabled'), + $waitIndicator = $this.find('.edit-waiting').show() + $errorStatus = $this.find('.edit-error').empty(); + + $.ajax({ + type: $this.attr('method'), + url: $this.attr('action'), + data: $this.serialize() + }).done(function(data) { + var $comment = $this.closest('.comment'); + $comment.find('.comment-content').html(markdown.render(data.comment)); + $comment.toggleClass('is-editing'); + }).fail(function(xhr) { + var error = xhr.responseJSON && xhr.responseJSON.error || 'An unknown error occurred.'; + $errorStatus.text(error); + }).always(function() { + $submitButton.removeAttr('disabled'); + $waitIndicator.hide(); + }); + }) }); function _format_time_difference(seconds) { diff --git a/nyaa/templates/view.html b/nyaa/templates/view.html index ae792d4..472c76e 100644 --- a/nyaa/templates/view.html +++ b/nyaa/templates/view.html @@ -152,18 +152,38 @@

{{ comment.user.userlevel_str }} -
-
+
+
{{ comment.created_time.strftime('%Y-%m-%d %H:%M UTC') }} - {% if g.user.is_moderator or g.user.id == comment.user_id %} -
- -
+ {% if comment.edited_time %} + (edited) {% endif %} +
+ {% if g.user.id == comment.user_id and not comment.editing_limit_exceeded %} + + {% endif %} + {% if g.user.is_moderator or g.user.id == comment.user_id %} +
+ +
+ {% endif %} +
{# Escape newlines into html entities because CF strips blank newlines #}
{{ comment.text }}
+ {% if g.user.id == comment.user_id %} +
+ {{ comment_form.csrf_token }} +
+ +
+ + + + +
+ {% endif %}
diff --git a/nyaa/views/torrents.py b/nyaa/views/torrents.py index f3e53eb..93c9243 100644 --- a/nyaa/views/torrents.py +++ b/nyaa/views/torrents.py @@ -330,6 +330,37 @@ def download_torrent(torrent_id): return resp +@bp.route('/view//comment//edit', methods=['POST']) +def edit_comment(torrent_id, comment_id): + if not flask.g.user: + flask.abort(403) + torrent = models.Torrent.by_id(torrent_id) + if not torrent: + flask.abort(404) + + comment = models.Comment.query.get(comment_id) + if not comment: + flask.abort(404) + + if not comment.user.id == flask.g.user.id: + flask.abort(403) + + if comment.editing_limit_exceeded: + flask.abort(flask.make_response(flask.jsonify( + {'error': 'Editing time limit exceeded.'}), 400)) + + form = forms.CommentForm(flask.request.form) + + if not form.validate(): + error_str = ' '.join(form.errors['comment']) + flask.abort(flask.make_response(flask.jsonify({'error': error_str}), 400)) + + comment.text = form.comment.data + db.session.commit() + + return flask.jsonify({'comment': comment.text}) + + @bp.route('/view//comment//delete', methods=['POST']) def delete_comment(torrent_id, comment_id): if not flask.g.user: