diff --git a/client/css/posts.styl b/client/css/posts.styl index ac8e19a..eb27199 100644 --- a/client/css/posts.styl +++ b/client/css/posts.styl @@ -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 diff --git a/client/html/post.tpl b/client/html/post.tpl index af98422..4e7458c 100644 --- a/client/html/post.tpl +++ b/client/html/post.tpl @@ -24,7 +24,7 @@
<% if (ctx.editMode) { %> - + Back to view mode <% } else { %> diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl index 29a5d6a..cf28ed3 100644 --- a/client/html/post_edit_sidebar.tpl +++ b/client/html/post_edit_sidebar.tpl @@ -1 +1,69 @@ -

Editing

+
+
+
+
+ + <%= 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'}) %> +
+ +
+ <%= ctx.makeTextInput({ + text: 'Tags', + value: ctx.post.tags.join(' '), + readonly: !ctx.canEditPostTags}) %> +
+ +
+ <%= 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}) %> +
+ + <% if ((ctx.editingNewPost && ctx.canCreateAnonymousPosts) || ctx.post.type === 'video') { %> +
+ + + <% if (ctx.editingNewPost && ctx.canCreateAnonymousPosts) { %> + <%= ctx.makeCheckbox({ + text: 'Don\'t show me as uploader', + name: 'anonymous'}) %> + <% } %> + + <% if (ctx.post.type === 'video') { %> + + <%= ctx.makeCheckbox({ + text: 'Loop video', + name: 'loop', + readonly: !ctx.canEditPostFlags}) %> + <% } %> +
+ <% } %> +
+
+ +
+ +
+
+
diff --git a/client/js/controllers/post_controller.js b/client/js/controllers/post_controller.js index d67c56f..397390e 100644 --- a/client/js/controllers/post_controller.js +++ b/client/js/controllers/post_controller.js @@ -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(); } diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js index e276237..fa1e776 100644 --- a/client/js/controls/post_edit_sidebar_control.js +++ b/client/js/controls/post_edit_sidebar_control.js @@ -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'); } }; diff --git a/client/js/controls/tag_input_control.js b/client/js/controls/tag_input_control.js index ac083d4..2ec5b0d 100644 --- a/client/js/controls/tag_input_control.js +++ b/client/js/controls/tag_input_control.js @@ -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() == diff --git a/client/js/models/post.js b/client/js/models/post.js index 37c8fe0..849651d 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -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); } }; diff --git a/client/js/util/misc.js b/client/js/util/misc.js index a3efede..7bcd632 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -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, }; diff --git a/client/js/views/tag_edit_view.js b/client/js/views/tag_edit_view.js index 5cf3ba1..35e0e8a 100644 --- a/client/js/views/tag_edit_view.js +++ b/client/js/views/tag_edit_view.js @@ -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, }, }));