[Schema+config change] Comment editing (#396)

* Comment editing
* Optional time limit for comment editing
This commit is contained in:
Shane 2017-11-05 09:26:30 -05:00 committed by Anna-Maria Meriniemi
parent b4c0ad9e84
commit 72c997173c
8 changed files with 185 additions and 8 deletions

View File

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

View File

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

View File

@ -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.')
])

View File

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

View File

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

View File

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

View File

@ -152,18 +152,38 @@
</p>
<img class="avatar" src="{{ comment.user.gravatar_url() }}" alt="{{ comment.user.userlevel_str }}">
</div>
<div class="col-md-10">
<div class="row">
<div class="col-md-10 comment">
<div class="row comment-details">
<a href="#com-{{ loop.index }}"><small data-timestamp-swap data-timestamp="{{ comment.created_utc_timestamp|int }}">{{ comment.created_time.strftime('%Y-%m-%d %H:%M UTC') }}</small></a>
{% if g.user.is_moderator or g.user.id == comment.user_id %}
<form class="delete-comment-form" action="{{ url_for('torrents.delete_comment', torrent_id=torrent.id, comment_id=comment.id) }}" method="POST">
<button name="submit" type="submit" class="btn btn-danger btn-xs" title="Delete">Delete</button>
</form>
{% if comment.edited_time %}
<small title="{{ comment.edited_time }}">(edited)</small>
{% endif %}
<div class="comment-actions">
{% if g.user.id == comment.user_id and not comment.editing_limit_exceeded %}
<button class="btn btn-xs edit-comment" title="Edit"{% if config.EDITING_TIME_LIMIT %} data-until="{{ comment.editable_until|int }}"{% endif %}>Edit</button>
{% endif %}
{% if g.user.is_moderator or g.user.id == comment.user_id %}
<form class="delete-comment-form" action="{{ url_for('torrents.delete_comment', torrent_id=torrent.id, comment_id=comment.id) }}" method="POST">
<button name="submit" type="submit" class="btn btn-danger btn-xs" title="Delete">Delete</button>
</form>
{% endif %}
</div>
</div>
<div class="row">
{# Escape newlines into html entities because CF strips blank newlines #}
<div markdown-text class="comment-content" id="torrent-comment{{ comment.id }}">{{ comment.text }}</div>
{% if g.user.id == comment.user_id %}
<form class="edit-comment-box" action="{{ url_for('torrents.edit_comment', torrent_id=torrent.id, comment_id=comment.id) }}" method="POST">
{{ comment_form.csrf_token }}
<div class="form-group">
<textarea class="form-control" name="comment" autofocus>{{ comment.text }}</textarea>
</div>
<input type="submit" value="Submit" class="btn btn-success btn-sm">
<button class="btn btn-sm edit-comment" title="Cancel">Cancel</button>
<span class="edit-error text-danger"></span>
<div class="edit-waiting" style="display:none"></div>
</form>
{% endif %}
</div>
</div>
</div>

View File

@ -330,6 +330,37 @@ def download_torrent(torrent_id):
return resp
@bp.route('/view/<int:torrent_id>/comment/<int:comment_id>/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/<int:torrent_id>/comment/<int:comment_id>/delete', methods=['POST'])
def delete_comment(torrent_id, comment_id):
if not flask.g.user: