client/posts: add simple editing

This commit is contained in:
rr- 2016-07-03 13:46:49 +02:00
parent 651c3f6925
commit 7488abb332
9 changed files with 281 additions and 57 deletions

View File

@ -310,3 +310,37 @@ $safety-unsafe = #F3985F
color: $inactive-link-color
padding-left: 0.7em
font-size: 90%
.post-view .edit-sidebar
section
margin-bottom: 1em
.safety
display: flex
flex-wrap: wrap
label:not(.radio)
width: 100%
.radio
flex-grow: 1
display: inline-block
.tags
.tag-input
min-height: 6.25em
label
margin-bottom: 0.3em
display: block
.buttons
display: flex
flex-wrap: wrap
margin-top: 2em
input, button
flex-grow: 1
input[type=submit],
input[type=button],
button
&:focus
border: 2px solid $text-color !important

View File

@ -24,7 +24,7 @@
<article class='edit-post'>
<% if (ctx.editMode) { %>
<a href='/post/<%- encodeURIComponent(ctx.post.id) %>'>
<i class='fa fa-eye'></i>
<i class='fa fa-reply'></i>
<span class='vim-nav-hint'>Back to view mode</span>
</a>
<% } else { %>

View File

@ -1 +1,69 @@
<h1>Editing</h1>
<div class='edit-sidebar'>
<form autocomplete='off'>
<div class='input'>
<section class='safety'>
<label>Safety</label>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-safe',
value: 'safe',
selectedValue: ctx.post.safety,
text: 'Safe'}) %>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-sketchy',
value: 'sketchy',
selectedValue: ctx.post.safety,
text: 'Sketchy'}) %>
<%= ctx.makeRadio({
name: 'safety',
value: 'unsafe',
selectedValue: ctx.post.safety,
class: 'safety-unsafe',
text: 'Unsafe'}) %>
</section>
<section class='tags'>
<%= ctx.makeTextInput({
text: 'Tags',
value: ctx.post.tags.join(' '),
readonly: !ctx.canEditPostTags}) %>
</section>
<section class='relations'>
<%= ctx.makeTextInput({
text: 'Relations',
name: 'relations',
placeholder: 'space-separated post IDs',
pattern: '^[0-9 ]*$',
value: ctx.post.relations.map(rel => rel.id).join(' '),
readonly: !ctx.canEditPostRelations}) %>
</section>
<% if ((ctx.editingNewPost && ctx.canCreateAnonymousPosts) || ctx.post.type === 'video') { %>
<section class='flags'>
<label>Miscellaneous</label>
<% if (ctx.editingNewPost && ctx.canCreateAnonymousPosts) { %>
<%= ctx.makeCheckbox({
text: 'Don\'t show me as uploader',
name: 'anonymous'}) %>
<% } %>
<% if (ctx.post.type === 'video') { %>
<!-- TODO: bind state -->
<%= ctx.makeCheckbox({
text: 'Loop video',
name: 'loop',
readonly: !ctx.canEditPostFlags}) %>
<% } %>
</section>
<% } %>
</div>
<div class='messages'></div>
<div class='buttons'>
<input class='encourage' type='submit' value='Submit' class='submit'/>
</div>
</form>
</div>

View File

@ -52,6 +52,10 @@ class PostController {
'score', e => this._evtScorePost(e));
this._view.sidebarControl.addEventListener(
'fitModeChange', e => this._evtFitModeChange(e));
this._view.sidebarControl.addEventListener(
'change', e => this._evtPostChange(e));
this._view.sidebarControl.addEventListener(
'submit', e => this._evtPostEdit(e));
}
if (this._view.commentFormControl) {
this._view.commentFormControl.addEventListener(
@ -93,6 +97,26 @@ class PostController {
settings.save(browsingSettings);
}
_evtPostEdit(e) {
// TODO: disable form
const post = e.detail.post;
post.tags = e.detail.tags;
post.safety = e.detail.safety;
post.relations = e.detail.relations;
post.save()
.then(() => {
misc.disableExitConfirmation();
// TODO: enable form
}, errorMessage => {
window.alert(errorMessage);
// TODO: enable form
});
}
_evtPostChange(e) {
misc.enableExitConfirmation();
}
_evtCommentChange(e) {
misc.enableExitConfirmation();
}

View File

@ -1,7 +1,10 @@
'use strict';
const api = require('../api.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const TagInputControl = require('./tag_input_control.js');
const template = views.getTemplate('post-edit-sidebar');
@ -14,7 +17,61 @@ class PostEditSidebarControl extends events.EventTarget {
views.replaceContent(this._hostNode, template({
post: this._post,
canEditPostContent: api.hasPrivilege('posts:edit:content'),
canEditPostFlags: api.hasPrivilege('posts:edit:flags'),
canEditPostNotes: api.hasPrivilege('posts:edit:notes'),
canEditPostRelations: api.hasPrivilege('posts:edit:relations'),
canEditPostSafety: api.hasPrivilege('posts:edit:safety'),
canEditPostSource: api.hasPrivilege('posts:edit:source'),
canEditPostTags: api.hasPrivilege('posts:edit:tags'),
canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'),
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
}));
if (this._formNode) {
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
this._tagControl = new TagInputControl(this._tagInputNode);
}
_evtSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {
detail: {
post: this._post,
safety:
Array.from(this._safetyButtonNodes)
.filter(node => node.checked)[0]
.value.toLowerCase(),
tags:
misc.splitByWhitespace(this._tagInputNode.value),
relations:
misc.splitByWhitespace(this._relationsInputNode.value),
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _submitButtonNode() {
return this._hostNode.querySelector('.submit');
}
get _safetyButtonNodes() {
return this._formNode.querySelectorAll('.safety input');
}
get _tagInputNode() {
return this._formNode.querySelector('.tags input');
}
get _relationsInputNode() {
return this._formNode.querySelector('.relations input');
}
};

View File

@ -3,6 +3,7 @@
const api = require('../api.js');
const tags = require('../tags.js');
const misc = require('../util/misc.js');
const events = require('../events.js');
const views = require('../util/views.js');
const TagAutoCompleteControl = require('./tag_auto_complete_control.js');
@ -16,8 +17,17 @@ const KEY_RETURN = 13;
const KEY_BACKSPACE = 8;
const KEY_DELETE = 46;
class TagInputControl {
class TagInputEvent extends Event {
constructor(name, tagInput) {
super(name);
this.tagInput = tagInput;
this.tags = tagInput.tags;
}
}
class TagInputControl extends events.EventTarget {
constructor(sourceInputNode) {
super();
this.tags = [];
this.readOnly = sourceInputNode.readOnly;
@ -90,6 +100,8 @@ class TagInputControl {
this._hideVisualCues();
this.tags.push(text);
this.dispatchEvent(new TagInputEvent('add', this));
this.dispatchEvent(new TagInputEvent('change', this));
this._sourceInputNode.value = this.tags.join(' ');
const sourceWrapperNode = this._getWrapperFromChild(sourceNode);
@ -128,6 +140,8 @@ class TagInputControl {
}
this._hideAutoComplete();
this.tags = this.tags.filter(t => t.toLowerCase() != tag.toLowerCase());
this.dispatchEvent(new TagInputEvent('remove', this));
this.dispatchEvent(new TagInputEvent('change', this));
this._sourceInputNode.value = this.tags.join(' ');
for (let wrapperNode of this._getAllWrapperNodes()) {
if (this._getTagFromWrapper(wrapperNode).toLowerCase() ==

View File

@ -5,9 +5,16 @@ const tags = require('../tags.js');
const events = require('../events.js');
const CommentList = require('./comment_list.js');
function _arraysDiffer(source1, source2) {
return [...source1].filter(value => !source2.includes(value)).length > 0
|| [...source2].filter(value => !source1.includes(value)).length > 0;
}
class Post extends events.EventTarget {
constructor() {
super();
this._orig = {};
this._id = null;
this._type = null;
this._mimeType = null;
@ -31,27 +38,31 @@ class Post extends events.EventTarget {
this._ownFavorite = null;
}
get id() { return this._id; }
get type() { return this._type; }
get mimeType() { return this._mimeType; }
get creationTime() { return this._creationTime; }
get user() { return this._user; }
get safety() { return this._safety; }
get contentUrl() { return this._contentUrl; }
get thumbnailUrl() { return this._thumbnailUrl; }
get canvasWidth() { return this._canvasWidth || 800; }
get canvasHeight() { return this._canvasHeight || 450; }
get fileSize() { return this._fileSize || 0; }
get id() { return this._id; }
get type() { return this._type; }
get mimeType() { return this._mimeType; }
get creationTime() { return this._creationTime; }
get user() { return this._user; }
get safety() { return this._safety; }
get contentUrl() { return this._contentUrl; }
get thumbnailUrl() { return this._thumbnailUrl; }
get canvasWidth() { return this._canvasWidth || 800; }
get canvasHeight() { return this._canvasHeight || 450; }
get fileSize() { return this._fileSize || 0; }
get tags() { return this._tags; }
get notes() { return this._notes; }
get comments() { return this._comments; }
get relations() { return this._relations; }
get tags() { return this._tags; }
get notes() { return this._notes; }
get comments() { return this._comments; }
get relations() { return this._relations; }
get score() { return this._score; }
get favoriteCount() { return this._favoriteCount; }
get ownFavorite() { return this._ownFavorite; }
get ownScore() { return this._ownScore; }
get score() { return this._score; }
get favoriteCount() { return this._favoriteCount; }
get ownFavorite() { return this._ownFavorite; }
get ownScore() { return this._ownScore; }
set tags(value) { this._tags = value; }
set safety(value) { this._safety = value; }
set relations(value) { this._relations = value; }
static fromResponse(response) {
const ret = new Post();
@ -90,15 +101,22 @@ class Post extends events.EventTarget {
}
save() {
let promise = null;
let data = {
tags: this._tags,
};
if (this._id) {
promise = api.put('/post/' + this._id, data);
} else {
promise = api.post('/posts', data);
const detail = {};
// send only changed fields to avoid user privilege violation
if (this._safety !== this._orig._safety) {
detail.safety = this._safety;
}
if (_arraysDiffer(this._tags, this._orig._tags)) {
detail.tags = this._tags;
}
if (_arraysDiffer(this._relations, this._orig._relations)) {
detail.relations = this._relations;
}
let promise = this._id ?
api.put('/post/' + this._id, detail) :
api.post('/posts', detail);
return promise.then(response => {
this._updateFromResponse(response);
@ -180,27 +198,32 @@ class Post extends events.EventTarget {
}
_updateFromResponse(response) {
this._id = response.id;
this._type = response.type;
this._mimeType = response.mimeType;
this._creationTime = response.creationTime;
this._user = response.user;
this._safety = response.safety;
this._contentUrl = response.contentUrl;
this._thumbnailUrl = response.thumbnailUrl;
this._canvasWidth = response.canvasWidth;
this._canvasHeight = response.canvasHeight;
this._fileSize = response.fileSize;
const map = {
_id: response.id,
_type: response.type,
_mimeType: response.mimeType,
_creationTime: response.creationTime,
_user: response.user,
_safety: response.safety,
_contentUrl: response.contentUrl,
_thumbnailUrl: response.thumbnailUrl,
_canvasWidth: response.canvasWidth,
_canvasHeight: response.canvasHeight,
_fileSize: response.fileSize,
this._tags = response.tags;
this._notes = response.notes;
this._comments = CommentList.fromResponse(response.comments || []);
this._relations = response.relations;
_tags: response.tags,
_notes: response.notes,
_comments: CommentList.fromResponse(response.comments || []),
_relations: response.relations,
this._score = response.score;
this._favoriteCount = response.favoriteCount;
this._ownScore = response.ownScore;
this._ownFavorite = response.ownFavorite;
_score: response.score,
_favoriteCount: response.favoriteCount,
_ownScore: response.ownScore,
_ownFavorite: response.ownFavorite,
};
Object.assign(this, map);
Object.assign(this._orig, map);
}
};

View File

@ -163,6 +163,10 @@ function formatUrlParameters(dict) {
return result.join(';');
}
function splitByWhitespace(str) {
return str.split(/\s+/).filter(s => s);
}
function parseUrlParameters(query) {
let result = {};
for (let word of (query || '').split(/;/)) {
@ -254,4 +258,5 @@ module.exports = {
confirmPageExit: confirmPageExit,
escapeHtml: escapeHtml,
makeCssName: makeCssName,
splitByWhitespace: splitByWhitespace,
};

View File

@ -2,15 +2,12 @@
const config = require('../config.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const views = require('../util/views.js');
const TagInputControl = require('../controls/tag_input_control.js');
const template = views.getTemplate('tag-edit');
function _split(str) {
return str.split(/\s+/).filter(s => s);
}
class TagEditView extends events.EventTarget {
constructor(ctx) {
super();
@ -58,7 +55,7 @@ class TagEditView extends events.EventTarget {
_evtNameInput(e) {
const regex = new RegExp(config.tagNameRegex);
const list = this._namesFieldNode.value.split(/\s+/).filter(t => t);
const list = misc.splitByWhitespace(this._namesFieldNode.value);
if (!list.length) {
this._namesFieldNode.setCustomValidity(
@ -82,10 +79,12 @@ class TagEditView extends events.EventTarget {
this.dispatchEvent(new CustomEvent('submit', {
detail: {
tag: this._tag,
names: _split(this._namesFieldNode.value),
names: misc.splitByWhitespace(this._namesFieldNode.value),
category: this._categoryFieldNode.value,
implications: _split(this._implicationsFieldNode.value),
suggestions: _split(this._suggestionsFieldNode.value),
implications: misc.splitByWhitespace(
this._implicationsFieldNode.value),
suggestions: misc.splitByWhitespace(
this._suggestionsFieldNode.value),
description: this._descriptionFieldNode.value,
},
}));