client/posts: add post merging

This commit is contained in:
rr- 2016-10-22 10:03:38 +02:00
parent 8c0fa7f49e
commit f1445b9c24
16 changed files with 502 additions and 24 deletions

View file

@ -0,0 +1,33 @@
#post
width: 100%
max-width: 40em
h1
margin-top: 0
form
width: 100%
.buttons i
margin-right: 0.5em
.post-merge
.left-post-container
width: 47%
float: left
.right-post-container
width: 47%
float: right
input[type=text]
width: 8em
margin-top: -2px
.post-mirror
margin-bottom: 1em
&:after
display: block
height: 1px
content: ' '
clear: both
.post-thumbnail .thumbnail
width: 100%
height: 9em
.target-post .thumbnail
margin-right: 0.35em
.target-post, .target-post-content
margin: 1em 0

View file

@ -0,0 +1,12 @@
<div class='content-wrapper' id='post'>
<h1>Post #<%- ctx.post.id %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li><a href='/post/<%- ctx.post.id %>'><i class='fa fa-reply'></i> Main view</a></li><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='/post/<%- ctx.post.id %>/merge'>Merge with&hellip;</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>
<div class='post-content-holder'></div>
</div>

View file

@ -84,12 +84,15 @@
</section>
<% } %>
<% if (ctx.canFeaturePosts || ctx.canDeletePosts) { %>
<% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
<section class='management'>
<ul>
<% if (ctx.canFeaturePosts) { %>
<li><a href class='feature'>Feature this post on main page</a></li>
<% } %>
<% if (ctx.canMergePosts) { %>
<li><a href class='merge'>Merge this post with another</a></li>
<% } %>
<% if (ctx.canDeletePosts) { %>
<li><a href class='delete'>Delete this post</a></li>
<% } %>

View file

@ -0,0 +1,23 @@
<div class='post-merge'>
<form>
<ul>
<li class='post-mirror'>
<div class='left-post-container'></div>
<div class='right-post-container'></div>
</li>
<li>
<p>Tags, relations, scores, favorites and comments will be
merged. All other properties need to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge these posts.'}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Merge posts'/>
</div>
</form>
</div>

View file

@ -0,0 +1,48 @@
<% if (ctx.editable) { %>
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/></p>
<% } else { %>
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/></p>
<% } %>
<% if (ctx.post) { %>
<div class='post-thumbnail'>
<a rel='external' href='<%- ctx.post.contentUrl %>'>
<%= ctx.makeThumbnail(ctx.post.thumbnailUrl) %>
</a>
</div>
<div class='target-post'>
<%= ctx.makeRadio({
required: true,
text: 'Merge to this post<br/><small>' +
ctx.makeUserLink(ctx.post.user) +
', ' +
ctx.makeRelativeTime(ctx.post.creationTime) +
'</small>',
name: 'target-post',
value: ctx.name,
}) %>
</div>
<div class='target-post-content'>
<%= ctx.makeRadio({
required: true,
text: 'Use this file<br/><small>' +
ctx.makeFileSize(ctx.post.fileSize) + ' ' +
{
'image/gif': 'GIF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'video/webm': 'WEBM',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] +
' (' +
(ctx.post.canvasWidth ?
`${ctx.post.canvasWidth}x${ctx.post.canvasHeight}` :
'?') +
')</small>',
name: 'target-post-content',
value: ctx.name,
}) %>
<p>
</p>
</div>
<% } %>

View file

@ -0,0 +1,20 @@
'use strict';
const api = require('../api.js');
const topNavigation = require('../models/top_navigation.js');
const EmptyView = require('../views/empty_view.js');
class BasePostController {
constructor(ctx) {
if (!api.hasPrivilege('posts:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Post #' + ctx.parameters.id.toString());
}
}
module.exports = BasePostController;

View file

@ -0,0 +1,86 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
const PostDetailView = require('../views/post_detail_view.js');
const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
class PostDetailController extends BasePostController {
constructor(ctx, section) {
super(ctx);
Post.get(ctx.parameters.id).then(post => {
this._id = ctx.parameters.id;
post.addEventListener('change', e => this._evtSaved(e, section));
this._view = new PostDetailView({
post: post,
section: section,
canMerge: api.hasPrivilege('posts:merge'),
});
this._view.addEventListener('select', e => this._evtSelect(e));
this._view.addEventListener('merge', e => this._evtMerge(e));
}, errorMessage => {
this._view = new EmptyView();
this._view.showError(errorMessage);
});
}
showSuccess(message) {
this._view.showSuccess(message);
}
_evtSelect(e) {
this._view.clearMessages();
this._view.disableForm();
Post.get(e.detail.postId).then(post => {
this._view.selectPost(post);
this._view.enableForm();
}, errorMessage => {
this._view.showError(errorMessage);
this._view.enableForm();
});
}
_evtSaved(e, section) {
misc.disableExitConfirmation();
if (this._id !== e.detail.post.id) {
router.replace(
'/post/' + e.detail.post.id + '/' + section, null, false);
}
}
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
.then(() => {
this._view = new PostDetailView({
post: e.detail.targetPost,
section: 'merge',
canMerge: api.hasPrivilege('posts:merge'),
});
this._view.showSuccess('Post merged.');
router.replace(
'/post/' + e.detail.targetPost.id + '/merge', null, false);
}, errorMessage => {
this._view.showError(errorMessage);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter(
'/post/:id/merge',
(ctx, next) => {
ctx.controller = new PostDetailController(ctx, 'merge');
});
};

View file

@ -7,26 +7,19 @@ const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
const topNavigation = require('../models/top_navigation.js');
const PostView = require('../views/post_view.js');
const PostMainView = require('../views/post_main_view.js');
const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
class PostController {
constructor(id, editMode, ctx) {
if (!api.hasPrivilege('posts:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Post #' + id.toString());
class PostMainController extends BasePostController {
constructor(ctx, editMode) {
super(ctx);
let parameters = ctx.parameters;
Promise.all([
Post.get(id),
Post.get(ctx.parameters.id),
PostList.getAround(
id, this._decorateSearchQuery(
ctx.parameters.id, this._decorateSearchQuery(
parameters ? parameters.query : '')),
]).then(responses => {
const [post, aroundResponse] = responses;
@ -36,13 +29,13 @@ class PostController {
if (parameters.query) {
ctx.state.parameters = parameters;
const url = editMode ?
'/post/' + id + '/edit' :
'/post/' + id;
'/post/' + ctx.parameters.id + '/edit' :
'/post/' + ctx.parameters.id;
router.replace(url, ctx.state, false);
}
this._post = post;
this._view = new PostView({
this._view = new PostMainView({
post: post,
editMode: editMode,
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
@ -72,6 +65,8 @@ class PostController {
'feature', e => this._evtFeaturePost(e));
this._view.sidebarControl.addEventListener(
'delete', e => this._evtDeletePost(e));
this._view.sidebarControl.addEventListener(
'merge', e => this._evtMergePost(e));
}
if (this._view.commentFormControl) {
@ -128,6 +123,10 @@ class PostController {
});
}
_evtMergePost(e) {
router.show('/post/' + e.detail.post.id + '/merge');
}
_evtDeletePost(e) {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
@ -262,7 +261,7 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, true, ctx);
ctx.controller = new PostMainController(ctx, true);
});
router.enter(
'/post/:id/:parameters(.*)?',
@ -272,6 +271,6 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, false, ctx);
ctx.controller = new PostMainController(ctx, false);
});
};

View file

@ -36,6 +36,7 @@ class PostEditSidebarControl extends events.EventTarget {
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
canMergePosts: api.hasPrivilege('posts:merge'),
}));
new ExpanderControl(
@ -108,6 +109,11 @@ class PostEditSidebarControl extends events.EventTarget {
'click', e => this._evtFeatureClick(e));
}
if (this._mergeLinkNode) {
this._mergeLinkNode.addEventListener(
'click', e => this._evtMergeClick(e));
}
if (this._deleteLinkNode) {
this._deleteLinkNode.addEventListener(
'click', e => this._evtDeleteClick(e));
@ -186,6 +192,15 @@ class PostEditSidebarControl extends events.EventTarget {
}
}
_evtMergeClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('merge', {
detail: {
post: this._post,
},
}));
}
_evtDeleteClick(e) {
e.preventDefault();
if (confirm('Are you sure you want to delete this post?')) {
@ -314,6 +329,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.management .feature');
}
get _mergeLinkNode() {
return this._formNode.querySelector('.management .merge');
}
get _deleteLinkNode() {
return this._formNode.querySelector('.management .delete');
}

View file

@ -34,7 +34,8 @@ controllers.push(require('./controllers/auth_controller.js'));
controllers.push(require('./controllers/password_reset_controller.js'));
controllers.push(require('./controllers/comments_controller.js'));
controllers.push(require('./controllers/snapshots_controller.js'));
controllers.push(require('./controllers/post_controller.js'));
controllers.push(require('./controllers/post_detail_controller.js'));
controllers.push(require('./controllers/post_main_controller.js'));
controllers.push(require('./controllers/post_list_controller.js'));
controllers.push(require('./controllers/post_upload_controller.js'));
controllers.push(require('./controllers/tag_controller.js'));

View file

@ -190,6 +190,31 @@ class Post extends events.EventTarget {
});
}
merge(targetId, useOldContent) {
return api.get('/post/' + encodeURIComponent(targetId))
.then(response => {
return api.post('/post-merge/', {
removeVersion: this._version,
remove: this._id,
mergeToVersion: response.version,
mergeTo: targetId,
replaceContent: useOldContent,
});
}, response => {
return Promise.reject(response);
}).then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
post: this,
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
setScore(score) {
return api.put('/post/' + this._id + '/score', {score: score})
.then(response => {

View file

@ -0,0 +1,80 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const PostMergeView = require('./post_merge_view.js');
const EmptyView = require('../views/empty_view.js');
const template = views.getTemplate('post-detail');
class PostDetailView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
ctx.post.addEventListener('change', e => this._evtChange(e));
ctx.section = ctx.section || 'summary';
this._hostNode = document.getElementById('content-holder');
this._install();
}
_install() {
const ctx = this._ctx;
views.replaceContent(this._hostNode, template(ctx));
for (let item of this._hostNode.querySelectorAll('[data-name]')) {
item.classList.toggle(
'active', item.getAttribute('data-name') === ctx.section);
}
ctx.hostNode = this._hostNode.querySelector('.post-content-holder');
if (ctx.section === 'merge') {
if (!this._ctx.canMerge) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to merge posts.');
} else {
this._view = new PostMergeView(ctx);
events.proxyEvent(this._view, this, 'select');
events.proxyEvent(this._view, this, 'submit', 'merge');
}
} else {
// this._view = new PostSummaryView(ctx);
}
views.syncScrollPosition();
}
clearMessages() {
this._view.clearMessages();
}
enableForm() {
this._view.enableForm();
}
disableForm() {
this._view.disableForm();
}
showSuccess(message) {
this._view.showSuccess(message);
}
showError(message) {
this._view.showError(message);
}
selectPost(post) {
this._view.selectPost(post);
}
_evtChange(e) {
this._ctx.post = e.detail.post;
this._install(this._ctx);
}
}
module.exports = PostDetailView;

View file

@ -13,9 +13,9 @@ const PostEditSidebarControl =
const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('post');
const template = views.getTemplate('post-main');
class PostView {
class PostMainView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
@ -118,4 +118,4 @@ class PostView {
}
}
module.exports = PostView;
module.exports = PostMainView;

View file

@ -0,0 +1,129 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
const KEY_RETURN = 13;
const template = views.getTemplate('post-merge');
const sideTemplate = views.getTemplate('post-merge-side');
class PostMergeView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
this._post = ctx.post;
this._hostNode = ctx.hostNode;
this._leftPost = ctx.post;
this._rightPost = null;
views.replaceContent(this._hostNode, template(this._ctx));
views.decorateValidator(this._formNode);
this._refreshLeftSide();
this._refreshRightSide();
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
selectPost(post) {
this._rightPost = post;
this._refreshRightSide();
}
_refreshLeftSide() {
views.replaceContent(
this._leftSideNode,
sideTemplate(Object.assign({}, this._ctx, {
post: this._leftPost,
name: 'left',
editable: false})));
}
_refreshRightSide() {
views.replaceContent(
this._rightSideNode,
sideTemplate(Object.assign({}, this._ctx, {
post: this._rightPost,
name: 'right',
editable: true})));
if (this._targetPostFieldNode) {
this._targetPostFieldNode.addEventListener(
'keydown', e => this._evtTargetPostFieldKeyDown(e));
}
}
_evtSubmit(e) {
e.preventDefault();
const checkedTargetPost = this._formNode.querySelector(
'.target-post :checked').value;
const checkedTargetPostContent = this._formNode.querySelector(
'.target-post-content :checked').value;
this.dispatchEvent(new CustomEvent('submit', {
detail: {
post: checkedTargetPost == 'left' ?
this._rightPost :
this._leftPost,
targetPost: checkedTargetPost == 'left' ?
this._leftPost :
this._rightPost,
useOldContent: checkedTargetPostContent !== checkedTargetPost,
},
}));
}
_evtTargetPostFieldKeyDown(e) {
const key = e.which;
if (key !== KEY_RETURN) {
return;
}
e.target.blur();
e.preventDefault();
this.dispatchEvent(new CustomEvent('select', {
detail: {
postId: this._targetPostFieldNode.value,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _leftSideNode() {
return this._hostNode.querySelector('.left-post-container');
}
get _rightSideNode() {
return this._hostNode.querySelector('.right-post-container');
}
get _targetPostFieldNode() {
return this._formNode.querySelector(
'.post-mirror input:not([readonly])[type=text]');
}
}
module.exports = PostMergeView;