mirror of
https://gitlab.com/SIGBUS/nyaa.git
synced 2024-12-22 14:30:01 +00:00
[Schema+config change] Comment editing (#396)
* Comment editing * Optional time limit for comment editing
This commit is contained in:
parent
b4c0ad9e84
commit
72c997173c
|
@ -127,3 +127,11 @@ ENABLE_ELASTIC_SEARCH_HIGHLIGHT = False
|
||||||
ES_MAX_SEARCH_RESULT = 1000
|
ES_MAX_SEARCH_RESULT = 1000
|
||||||
# ES index name generally (nyaa or sukebei)
|
# ES index name generally (nyaa or sukebei)
|
||||||
ES_INDEX_NAME = SITE_FLAVOR
|
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
|
||||||
|
|
|
@ -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 ###
|
|
@ -184,7 +184,7 @@ class CommentForm(FlaskForm):
|
||||||
comment = TextAreaField('Make a comment', [
|
comment = TextAreaField('Make a comment', [
|
||||||
Length(min=3, max=1024, message='Comment must be at least %(min)d characters '
|
Length(min=3, max=1024, message='Comment must be at least %(min)d characters '
|
||||||
'long and %(max)d at most.'),
|
'long and %(max)d at most.'),
|
||||||
DataRequired()
|
DataRequired(message='Comment must not be empty.')
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -433,6 +433,7 @@ class CommentBase(DeclarativeHelperBase):
|
||||||
return db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'))
|
return db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'))
|
||||||
|
|
||||||
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow)
|
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)
|
text = db.Column(TextType(collation=COL_UTF8MB4_BIN), nullable=False)
|
||||||
|
|
||||||
@declarative.declared_attr
|
@declarative.declared_attr
|
||||||
|
@ -448,6 +449,15 @@ class CommentBase(DeclarativeHelperBase):
|
||||||
''' Returns a UTC POSIX timestamp, as seconds '''
|
''' Returns a UTC POSIX timestamp, as seconds '''
|
||||||
return (self.created_time - UTC_EPOCH).total_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):
|
class UserLevelType(IntEnum):
|
||||||
REGULAR = 0
|
REGULAR = 0
|
||||||
|
|
|
@ -271,6 +271,10 @@ a.text-purple:hover, a.text-purple:active, a.text-purple:focus {
|
||||||
color: #a760e0;
|
color: #a760e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-details {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-content {
|
.comment-content {
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
@ -286,11 +290,39 @@ a.text-purple:hover, a.text-purple:active, a.text-purple:focus {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-comment-form {
|
.comment-actions {
|
||||||
position: relative;
|
position: relative;
|
||||||
float: right;
|
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 {
|
#comment {
|
||||||
height: 8em;
|
height: 8em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,6 +74,52 @@ $(document).ready(function() {
|
||||||
$(this).blur().children('i').toggleClass('fa-folder-open fa-folder');
|
$(this).blur().children('i').toggleClass('fa-folder-open fa-folder');
|
||||||
$(this).next().stop().slideToggle(250);
|
$(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) {
|
function _format_time_difference(seconds) {
|
||||||
|
|
|
@ -152,18 +152,38 @@
|
||||||
</p>
|
</p>
|
||||||
<img class="avatar" src="{{ comment.user.gravatar_url() }}" alt="{{ comment.user.userlevel_str }}">
|
<img class="avatar" src="{{ comment.user.gravatar_url() }}" alt="{{ comment.user.userlevel_str }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-10">
|
<div class="col-md-10 comment">
|
||||||
<div class="row">
|
<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>
|
<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 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 %}
|
{% 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">
|
<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>
|
<button name="submit" type="submit" class="btn btn-danger btn-xs" title="Delete">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{# Escape newlines into html entities because CF strips blank newlines #}
|
{# Escape newlines into html entities because CF strips blank newlines #}
|
||||||
<div markdown-text class="comment-content" id="torrent-comment{{ comment.id }}">{{ comment.text }}</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -330,6 +330,37 @@ def download_torrent(torrent_id):
|
||||||
return resp
|
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'])
|
@bp.route('/view/<int:torrent_id>/comment/<int:comment_id>/delete', methods=['POST'])
|
||||||
def delete_comment(torrent_id, comment_id):
|
def delete_comment(torrent_id, comment_id):
|
||||||
if not flask.g.user:
|
if not flask.g.user:
|
||||||
|
|
Loading…
Reference in a new issue