Added basic post editing

This commit is contained in:
Marcin Kurczewski 2014-09-25 19:11:41 +02:00
parent 80b7aaf7d1
commit d2447045dd
16 changed files with 364 additions and 20 deletions

6
TODO
View file

@ -8,11 +8,7 @@ everything related to posts:
- fav - fav
- score (see notes about scoring) - score (see notes about scoring)
- editing - editing
- tags - concurrency
- source
- safety
- content
- thumb
- relations - relations
- ability to loop video posts - ability to loop video posts
- post edit history (think - post edit history (think

View file

@ -13,6 +13,7 @@ activationBodyPath = mail/activation.txt
[database] [database]
dsn = sqlite:db.sqlite dsn = sqlite:db.sqlite
maxPostSize = 10485760 ;10mb maxPostSize = 10485760 ;10mb
maxCustomThumbnailSize = 1048576 ;1mb
[security] [security]
secret = change secret = change
@ -43,6 +44,11 @@ uploadPosts = regularUser, powerUser, moderator, administrator
uploadPostsAnonymously = regularUser, powerUser, moderator, administrator uploadPostsAnonymously = regularUser, powerUser, moderator, administrator
deletePosts = moderator, administrator deletePosts = moderator, administrator
featurePosts = moderator, administrator featurePosts = moderator, administrator
changePostSafety = powerUser, moderator, administrator
changePostSource = regularUser, powerUser, moderator, administrator
changePostTags = regularUser, powerUser, moderator, administrator
changePostContent = regularUser, powerUser, moderator, administrator
changePostThumbnail = regularUser, powerUser, moderator, administrator
listTags = anonymous, regularUser, powerUser, moderator, administrator listTags = anonymous, regularUser, powerUser, moderator, administrator

View file

@ -113,3 +113,14 @@
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
} }
#post-view-wrapper .post-edit-wrapper {
padding: 1em;
position: absolute;
background: rgba(255, 255, 255, 0.8);
display: none;
}
#post-view-wrapper .post-edit-wrapper .file-handler {
margin: 0.5em 0;
}

View file

@ -25,6 +25,11 @@ App.Auth = function(_, jQuery, util, api, appState, promise) {
uploadPostsAnonymously: 'uploadPostsAnonymously', uploadPostsAnonymously: 'uploadPostsAnonymously',
deletePosts: 'deletePosts', deletePosts: 'deletePosts',
featurePosts: 'featurePosts', featurePosts: 'featurePosts',
changePostSafety: 'changePostSafety',
changePostSource: 'changePostSource',
changePostTags: 'changePostTags',
changePostContent: 'changePostContent',
changePostThumbnail: 'changePostThumbnail',
listTags: 'listTags', listTags: 'listTags',
}; };

View file

@ -12,11 +12,13 @@ App.Controls.TagInput = function(
var KEY_SPACE = 32; var KEY_SPACE = 32;
var KEY_BACKSPACE = 8; var KEY_BACKSPACE = 8;
var tagConfirmKeys = [KEY_RETURN, KEY_SPACE]; var tagConfirmKeys = [KEY_RETURN, KEY_SPACE];
var inputConfirmKeys = [KEY_RETURN];
var tags = []; var tags = [];
var options = { var options = {
beforeTagAdded: null, beforeTagAdded: null,
beforeTagRemoved: null, beforeTagRemoved: null,
inputConfirmed: null,
}; };
if ($underlyingInput.length !== 1) { if ($underlyingInput.length !== 1) {
@ -40,6 +42,9 @@ App.Controls.TagInput = function(
}); });
$input.attr('placeholder', $underlyingInput.attr('placeholder')); $input.attr('placeholder', $underlyingInput.attr('placeholder'));
pasteText($underlyingInput.val());
$underlyingInput.val('');
$input.unbind('focus').bind('focus', function(e) { $input.unbind('focus').bind('focus', function(e) {
$wrapper.addClass('focused'); $wrapper.addClass('focused');
}); });
@ -58,16 +63,25 @@ App.Controls.TagInput = function(
} else { } else {
pastedText = (e.originalEvent || e).clipboardData.getData('text/plain'); pastedText = (e.originalEvent || e).clipboardData.getData('text/plain');
} }
var patedTags = pastedText.split(/\s+/); pasteText(pastedText);
_.each(patedTags, function(tag) {
addTag(tag);
});
}); });
function pasteText(pastedText) {
var pastedTags = pastedText.split(/\s+/);
_.each(pastedTags, function(tag) {
addTag(tag);
});
}
$input.unbind('keydown').bind('keydown', function(e) { $input.unbind('keydown').bind('keydown', function(e) {
if (_.contains(tagConfirmKeys, e.which)) { if (_.contains(inputConfirmKeys, e.which) && !$input.val()) {
e.preventDefault(); e.preventDefault();
if (typeof(options.inputConfirmed) !== 'undefined') {
options.inputConfirmed();
}
} else if (_.contains(tagConfirmKeys, e.which)) {
var tag = $input.val(); var tag = $input.val();
e.preventDefault();
addTag(tag); addTag(tag);
$input.val(''); $input.val('');
} else if (e.which === KEY_BACKSPACE && jQuery(this).val().length === 0) { } else if (e.which === KEY_BACKSPACE && jQuery(this).val().length === 0) {

View file

@ -9,16 +9,24 @@ App.Presenters.PostPresenter = function(
api, api,
auth, auth,
router, router,
keyboard,
topNavigationPresenter, topNavigationPresenter,
messagePresenter) { messagePresenter) {
var $el = jQuery('#content'); var $el = jQuery('#content');
var $messages = $el; var $messages = $el;
var postTemplate; var postTemplate;
var postEditTemplate;
var postContentTemplate; var postContentTemplate;
var post; var post;
var postNameOrId; var postNameOrId;
var privileges = {}; var privileges = {};
var editPrivileges = {};
var tagInput;
var postContentFileDropper;
var postThumbnailFileDropper;
var postContent;
var postThumbnail;
function init(args, loaded) { function init(args, loaded) {
postNameOrId = args.postNameOrId; postNameOrId = args.postNameOrId;
@ -26,16 +34,24 @@ App.Presenters.PostPresenter = function(
privileges.canDeletePosts = auth.hasPrivilege(auth.privileges.deletePosts); privileges.canDeletePosts = auth.hasPrivilege(auth.privileges.deletePosts);
privileges.canFeaturePosts = auth.hasPrivilege(auth.privileges.featurePosts); privileges.canFeaturePosts = auth.hasPrivilege(auth.privileges.featurePosts);
editPrivileges.canChangeSafety = auth.hasPrivilege(auth.privileges.changePostSafety);
editPrivileges.canChangeSource = auth.hasPrivilege(auth.privileges.changePostSource);
editPrivileges.canChangeTags = auth.hasPrivilege(auth.privileges.changePostTags);
editPrivileges.canChangeContent = auth.hasPrivilege(auth.privileges.changePostContent);
editPrivileges.canChangeThumbnail = auth.hasPrivilege(auth.privileges.changePostThumbnail);
promise.waitAll( promise.waitAll(
util.promiseTemplate('post'), util.promiseTemplate('post'),
util.promiseTemplate('post-edit'),
util.promiseTemplate('post-content'), util.promiseTemplate('post-content'),
api.get('/posts/' + postNameOrId)) api.get('/posts/' + postNameOrId))
.then(function( .then(function(
postTemplateHtml, postTemplateHtml,
postEditTemplateHtml,
postContentTemplateHtml, postContentTemplateHtml,
response) { response) {
postTemplate = _.template(postTemplateHtml); postTemplate = _.template(postTemplateHtml);
postEditTemplate = _.template(postEditTemplateHtml);
postContentTemplate = _.template(postContentTemplateHtml); postContentTemplate = _.template(postContentTemplateHtml);
post = response.json; post = response.json;
@ -50,17 +66,44 @@ App.Presenters.PostPresenter = function(
} }
function render() { function render() {
$el.html(postTemplate({ $el.html(renderPostTemplate());
$messages = $el.find('.messages');
tagInput = App.Controls.TagInput($el.find('form [name=tags]'), _, jQuery);
tagInput.inputConfirmed = editPost;
postContentFileDropper = new App.Controls.FileDropper($el.find('form [name=content]'), _, jQuery);
postContentFileDropper.onChange = postContentChanged;
postContentFileDropper.setNames = true;
postThumbnailFileDropper = new App.Controls.FileDropper($el.find('form [name=thumbnail]'), _, jQuery);
postThumbnailFileDropper.onChange = postThumbnailChanged;
postThumbnailFileDropper.setNames = true;
keyboard.keyup('e', function() {
editButtonClicked(null);
});
$el.find('.post-edit-wrapper form').submit(editFormSubmitted);
$el.find('.delete').click(deleteButtonClicked);
$el.find('.feature').click(featureButtonClicked);
$el.find('.edit').click(editButtonClicked);
}
function renderSidebar() {
$el.find('#sidebar').html(jQuery(renderPostTemplate()).find('#sidebar').html());
}
function renderPostTemplate() {
return postTemplate({
post: post, post: post,
formatRelativeTime: util.formatRelativeTime, formatRelativeTime: util.formatRelativeTime,
formatFileSize: util.formatFileSize, formatFileSize: util.formatFileSize,
postContentTemplate: postContentTemplate, postContentTemplate: postContentTemplate,
postEditTemplate: postEditTemplate,
privileges: privileges, privileges: privileges,
})); editPrivileges: editPrivileges,
$messages = $el.find('.messages'); });
$el.find('.delete').click(deleteButtonClicked);
$el.find('.feature').click(featureButtonClicked);
} }
function deleteButtonClicked(e) { function deleteButtonClicked(e) {
@ -94,6 +137,89 @@ App.Presenters.PostPresenter = function(
.fail(showGenericError); .fail(showGenericError);
} }
function editButtonClicked(e) {
if (e) {
e.preventDefault();
}
messagePresenter.hideMessages($messages);
if ($el.find('.post-edit-wrapper').is(':visible')) {
hideEditForm();
} else {
showEditForm();
}
}
function editFormSubmitted(e) {
e.preventDefault();
editPost();
}
function showEditForm() {
$el.find('.post-edit-wrapper').slideDown('fast');
util.enableExitConfirmation();
tagInput.focus();
}
function hideEditForm() {
$el.find('.post-edit-wrapper').slideUp('fast');
util.disableExitConfirmation();
}
function editPost() {
var $form = $el.find('form');
var formData = {};
if (editPrivileges.canChangeContent && postContent) {
formData.content = postContent;
}
if (editPrivileges.canChangeThumbnail && postThumbnail) {
formData.thumbnail = postThumbnail;
}
if (editPrivileges.canChangeSource) {
formData.source = $form.find('[name=source]').val();
}
if (editPrivileges.canChangeSafety) {
formData.safety = $form.find('[name=safety]:checked').val();
}
if (editPrivileges.canChangeTags) {
formData.tags = tagInput.getTags().join(' ');
}
if (post.tags.length === 0) {
showEditError('No tags set.');
return;
}
promise.wait(api.put('/posts/' + post.id, formData))
.then(function(response) {
post = response.json;
hideEditForm();
renderSidebar();
}).fail(function(response) {
showEditError(response);
});
}
function postContentChanged(files) {
postContentFileDropper.readAsDataURL(files[0], function(content) {
postContent = content;
});
}
function postThumbnailChanged(files) {
postThumbnailFileDropper.readAsDataURL(files[0], function(content) {
postThumbnail = content;
});
}
function showEditError(response) {
window.alert(response.json && response.json.error || response);
}
function showGenericError(response) { function showGenericError(response) {
messagePresenter.showError($messages, response.json && response.json.error || response); messagePresenter.showError($messages, response.json && response.json.error || response);
} }
@ -105,4 +231,4 @@ App.Presenters.PostPresenter = function(
}; };
App.DI.register('postPresenter', ['_', 'jQuery', 'util', 'promise', 'api', 'auth', 'router', 'topNavigationPresenter', 'messagePresenter'], App.Presenters.PostPresenter); App.DI.register('postPresenter', ['_', 'jQuery', 'util', 'promise', 'api', 'auth', 'router', 'keyboard', 'topNavigationPresenter', 'messagePresenter'], App.Presenters.PostPresenter);

View file

@ -0,0 +1,66 @@
<form class="form-wrapper post-edit">
<% if (privileges.canChangeSafety) { %>
<div class="form-row">
<label class="form-label">Safety:</label>
<div class="form-input">
<input type="radio" id="post-safety-safe" name="safety" value="safe" <%= post.safety === 'safe' ? 'checked="checked"' : '' %>/>
<label for="post-safety-safe">
Safe
</label>
<input type="radio" id="post-safety-sketchy" name="safety" value="sketchy" <%= post.safety === 'sketchy' ? 'checked="checked"' : '' %>/>
<label for="post-safety-sketchy">
Sketchy
</label>
<input type="radio" id="post-safety-unsafe" name="safety" value="unsafe" <%= post.safety === 'unsafe' ? 'checked="checked"' : '' %>/>
<label for="post-safety-unsafe">
Unsafe
</label>
</div>
</div>
<% } %>
<% if (privileges.canChangeTags) { %>
<div class="form-row">
<label class="form-label" for="post-tags">Tags:</label>
<div class="form-input">
<input type="text" name="tags" id="post-tags" placeholder="Enter some tags&hellip;" value="<%= _.pluck(post.tags, 'name').join(' ') %>"/>
</div>
</div>
<% } %>
<% if (privileges.canChangeSource) { %>
<div class="form-row">
<label class="form-label" for="post-source">Source:</label>
<div class="form-input">
<input maxlength="200" type="text" name="source" id="post-source" placeholder="Where did you get this? (optional)" value="<%= post.source %>"/>
</div>
</div>
<% } %>
<% if (privileges.canChangeContent) { %>
<div class="form-row">
<label class="form-label" for="post-content">Content:</label>
<div class="form-input">
<input type="file" id="post-content" name="content"/>
</div>
</div>
<% } %>
<% if (privileges.canChangeThumbnail) { %>
<div class="form-row">
<label class="form-label" for="post-thumbnail">Thumbnail:</label>
<div class="form-input">
<input type="file" id="post-thumbnail" name="thumbnail"/>
</div>
</div>
<% } %>
<div class="form-row">
<label class="form-label"></label>
<div class="form-input">
<button type="submit">Update</button>
</div>
</div>
</form>

View file

@ -87,10 +87,18 @@
</ul> </ul>
<% if (_.any(privileges)) { %> <% if (_.any(privileges) || _.any(editPrivileges)) { %>
<h1>Options</h1> <h1>Options</h1>
<ul class="operations"> <ul class="operations">
<% if (_.any(editPrivileges)) { %>
<li>
<a href="#" class="edit">
Edit
</a>
</li>
<% } %>
<% if (privileges.canDeletePosts) { %> <% if (privileges.canDeletePosts) { %>
<li> <li>
<a href="#" class="delete"> <a href="#" class="delete">
@ -114,6 +122,10 @@
<div id="post-view"> <div id="post-view">
<div class="messages"></div> <div class="messages"></div>
<div class="post-edit-wrapper">
<%= postEditTemplate({post: post, privileges: editPrivileges}) %>
</div>
<%= postContentTemplate({post: post}) %> <%= postContentTemplate({post: post}) %>
</div> </div>
</div> </div>

View file

@ -26,6 +26,7 @@ final class PostController extends AbstractController
$router->get('/api/posts', [$this, 'getFiltered']); $router->get('/api/posts', [$this, 'getFiltered']);
$router->get('/api/posts/featured', [$this, 'getFeatured']); $router->get('/api/posts/featured', [$this, 'getFeatured']);
$router->get('/api/posts/:postNameOrId', [$this, 'getByNameOrId']); $router->get('/api/posts/:postNameOrId', [$this, 'getByNameOrId']);
$router->put('/api/posts/:postNameOrId', [$this, 'updatePost']);
$router->delete('/api/posts/:postNameOrId', [$this, 'deletePost']); $router->delete('/api/posts/:postNameOrId', [$this, 'deletePost']);
$router->post('/api/posts/:postNameOrId/feature', [$this, 'featurePost']); $router->post('/api/posts/:postNameOrId/feature', [$this, 'featurePost']);
$router->put('/api/posts/:postNameOrId/feature', [$this, 'featurePost']); $router->put('/api/posts/:postNameOrId/feature', [$this, 'featurePost']);
@ -68,6 +69,31 @@ final class PostController extends AbstractController
return $this->postViewProxy->fromEntity($post); return $this->postViewProxy->fromEntity($post);
} }
public function updatePost($postNameOrId)
{
$post = $this->postService->getByNameOrId($postNameOrId);
$formData = new \Szurubooru\FormData\PostEditFormData($this->inputReader);
if ($formData->content !== null)
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::CHANGE_POST_CONTENT);
if ($formData->thumbnail !== null)
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::CHANGE_POST_THUMBNAIL);
if ($formData->safety !== null)
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::CHANGE_POST_SAFETY);
if ($formData->source !== null)
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::CHANGE_POST_SOURCE);
if ($formData->tags !== null)
$this->privilegeService->assertPrivilege(\Szurubooru\Privilege::CHANGE_POST_TAGS);
$this->postService->updatePost($post, $formData);
$post = $this->postService->getByNameOrId($postNameOrId);
return $this->postViewProxy->fromEntity($post);
}
public function deletePost($postNameOrId) public function deletePost($postNameOrId)
{ {
$post = $this->postService->getByNameOrId($postNameOrId); $post = $this->postService->getByNameOrId($postNameOrId);

View file

@ -151,7 +151,7 @@ class PostDao extends AbstractDao implements ICrudDao
} }
foreach ($tagRelationsToDelete as $tagId) foreach ($tagRelationsToDelete as $tagId)
{ {
$this->fpdo->deleteFrom('postTags')->where('postId', $post->getId())->and('tagId', $tagId)->execute(); $this->fpdo->deleteFrom('postTags')->where('postId', $post->getId())->where('tagId', $tagId)->execute();
} }
} }
} }

View file

@ -0,0 +1,31 @@
<?php
namespace Szurubooru\FormData;
class PostEditFormData implements \Szurubooru\IValidatable
{
public $content;
public $thumbnail;
public $safety;
public $source;
public $tags;
public function __construct($inputReader = null)
{
if ($inputReader !== null)
{
$this->content = $inputReader->decodeBase64($inputReader->content);
$this->thumbnail = $inputReader->decodebase64($inputReader->thumbnail);
$this->safety = \Szurubooru\Helpers\EnumHelper::postSafetyFromString($inputReader->safety);
$this->source = $inputReader->source;
$this->tags = preg_split('/[\s+]/', $inputReader->tags);
}
}
public function validate(\Szurubooru\Validator $validator)
{
$validator->validatePostTags($this->tags);
if ($this->source !== null)
$validator->validatePostSource($this->source);
}
}

View file

@ -33,7 +33,7 @@ class UploadFormData implements \Szurubooru\IValidatable
$validator->validatePostTags($this->tags); $validator->validatePostTags($this->tags);
if ($this->source !== null) if ($this->source !== null)
$validator->validateMaxLength($this->source, 200, 'Source'); $validator->validatePostSource($this->source);
} }
} }

View file

@ -25,6 +25,11 @@ class Privilege
const UPLOAD_POSTS_ANONYMOUSLY = 'uploadPostsAnonymously'; const UPLOAD_POSTS_ANONYMOUSLY = 'uploadPostsAnonymously';
const DELETE_POSTS = 'deletePosts'; const DELETE_POSTS = 'deletePosts';
const FEATURE_POSTS = 'featurePosts'; const FEATURE_POSTS = 'featurePosts';
const CHANGE_POST_SAFETY = 'changePostSafety';
const CHANGE_POST_SOURCE = 'changePostSource';
const CHANGE_POST_TAGS = 'changePostTags';
const CHANGE_POST_CONTENT = 'changePostContent';
const CHANGE_POST_THUMBNAIL = 'changePostThumbnail';
const LIST_TAGS = 'listTags'; const LIST_TAGS = 'listTags';
} }

View file

@ -110,6 +110,32 @@ class PostService
return $this->transactionManager->commit($transactionFunc); return $this->transactionManager->commit($transactionFunc);
} }
public function updatePost(\Szurubooru\Entities\Post $post, \Szurubooru\FormData\PostEditFormData $formData)
{
$transactionFunc = function() use ($post, $formData)
{
$this->validator->validate($formData);
if ($formData->content !== null)
$this->updatePostContentFromString($post, $formData->content);
if ($formData->thumbnail !== null)
$this->updatePostThumbnailFromString($post, $formData->thumbnail);
if ($formData->safety !== null)
$this->updatePostSafety($post, $formData->safety);
if ($formData->source !== null)
$this->updatePostSource($post, $formData->source);
if ($formData->tags !== null)
$this->updatePostTags($post, $formData->tags);
return $this->postDao->save($post);
};
return $this->transactionManager->commit($transactionFunc);
}
private function updatePostSafety(\Szurubooru\Entities\Post $post, $newSafety) private function updatePostSafety(\Szurubooru\Entities\Post $post, $newSafety)
{ {
$post->setSafety($newSafety); $post->setSafety($newSafety);
@ -193,6 +219,14 @@ class PostService
} }
} }
private function updatePostThumbnailFromString(\Szurubooru\Entities\Post $post, $newThumbnail)
{
if (strlen($newThumbnail) > $this->config->database->maxCustomThumbnailSize)
throw new \DomainException('Thumbnail is too big.');
$post->setThumbnailSourceContent($newThumbnail);
}
private function updatePostTags(\Szurubooru\Entities\Post $post, array $newTagNames) private function updatePostTags(\Szurubooru\Entities\Post $post, array $newTagNames)
{ {
$tags = []; $tags = [];

View file

@ -215,6 +215,13 @@ class UserService
private function updateUserAvatarContent(\Szurubooru\Entities\User $user, $newAvatarContent) private function updateUserAvatarContent(\Szurubooru\Entities\User $user, $newAvatarContent)
{ {
$mime = \Szurubooru\Helpers\MimeHelper::getMimeTypeFromBuffer($newAvatarContent);
if (!\Szurubooru\Helpers\MimeHelper::isImage($mime))
throw new \DomainException('Avatar must be an image.');
if (strlen($newAvatarContent) > $this->config->database->maxCustomThumbnailSize)
throw new \DomainException('Upload is too big.');
$user->setCustomAvatarSourceContent($newAvatarContent); $user->setCustomAvatarSourceContent($newAvatarContent);
} }

View file

@ -104,6 +104,11 @@ class Validator
} }
} }
public function validatePostSource($source)
{
$this->validateMaxLength($source, 200, 'Source');
}
public function validateToken($token) public function validateToken($token)
{ {
$this->validateNonEmpty($token, 'Token'); $this->validateNonEmpty($token, 'Token');