From 5bcf44aa2de4a0f4e87fee7c36eaed40874165b6 Mon Sep 17 00:00:00 2001 From: rr- Date: Sat, 20 Aug 2016 22:40:25 +0200 Subject: [PATCH] client/posts: implement upload form --- client/css/post-upload.styl | 45 +++ client/html/post_upload.tpl | 11 + client/html/post_upload_row.tpl | 40 +++ client/js/controllers/post_controller.js | 4 +- .../js/controllers/post_upload_controller.js | 50 +++- client/js/models/post.js | 82 +++--- client/js/util/views.js | 2 + client/js/views/post_upload_view.js | 266 ++++++++++++++++++ 8 files changed, 459 insertions(+), 41 deletions(-) create mode 100644 client/css/post-upload.styl create mode 100644 client/html/post_upload.tpl create mode 100644 client/html/post_upload_row.tpl create mode 100644 client/js/views/post_upload_view.js diff --git a/client/css/post-upload.styl b/client/css/post-upload.styl new file mode 100644 index 0000000..c0151d9 --- /dev/null +++ b/client/css/post-upload.styl @@ -0,0 +1,45 @@ +@import colors + +.post-upload + form + width: 100% + max-width: 40em + margin: 0 auto + + &.inactive + input[type=submit] + display: none + + .dropper-container + margin: 0 auto + .file-dropper + font-size: 150% + padding: 2em + + input[type=submit] + float: left + + .uploadables-container + margin: 2em 0 + text-align: left + line-height: 200% + + .uploadable + .file + overflow: hidden + text-align: left + text-overflow: ellipsis + + .safety + label + margin-right: 1em + + .thumbnail + float: left + width: 12.5em + height: 7em + margin: 0 1em 0 0 + + .remove + float: right + color: $inactive-link-color diff --git a/client/html/post_upload.tpl b/client/html/post_upload.tpl new file mode 100644 index 0000000..1d79d88 --- /dev/null +++ b/client/html/post_upload.tpl @@ -0,0 +1,11 @@ +
+
+
+ +
    + +
    + + +
    +
    diff --git a/client/html/post_upload_row.tpl b/client/html/post_upload_row.tpl new file mode 100644 index 0000000..aae1aed --- /dev/null +++ b/client/html/post_upload_row.tpl @@ -0,0 +1,40 @@ +
  • + + + + +
    + <% if (['image'].includes(ctx.uploadable.type)) { %> + + <%= ctx.makeThumbnail(ctx.uploadable.imageUrl) %> + + <% } else { %> + + <%= ctx.makeThumbnail(null) %> + + <% } %> +
    + +
    + <%= ctx.uploadable.name %> +
    + +
    + <% for (let safety of ['safe', 'sketchy', 'unsafe']) { %> + <%= ctx.makeRadio({ + name: 'safety-' + ctx.uploadable.key, + value: safety, + text: safety[0].toUpperCase() + safety.substr(1), + selectedValue: ctx.uploadable.safety, + }) %> + <% } %> +
    + +
    + <%= ctx.makeCheckbox({ + text: 'Upload anonymously', + name: 'anonymous', + checked: ctx.uploadable.anonymous, + }) %> +
    +
  • diff --git a/client/js/controllers/post_controller.js b/client/js/controllers/post_controller.js index 8a440a0..6701a17 100644 --- a/client/js/controllers/post_controller.js +++ b/client/js/controllers/post_controller.js @@ -148,10 +148,10 @@ class PostController { post.relations = e.detail.relations; } if (e.detail.content !== undefined) { - post.content = e.detail.content; + post.newContent = e.detail.content; } if (e.detail.thumbnail !== undefined) { - post.thumbnail = e.detail.thumbnail; + post.newThumbnail = e.detail.thumbnail; } post.save() .then(() => { diff --git a/client/js/controllers/post_upload_controller.js b/client/js/controllers/post_upload_controller.js index 82f3b49..aec34d5 100644 --- a/client/js/controllers/post_upload_controller.js +++ b/client/js/controllers/post_upload_controller.js @@ -1,13 +1,59 @@ 'use strict'; +const router = require('../router.js'); +const misc = require('../util/misc.js'); const topNavigation = require('../models/top_navigation.js'); -const EmptyView = require('../views/empty_view.js'); +const Post = require('../models/post.js'); +const PostUploadView = require('../views/post_upload_view.js'); class PostUploadController { constructor() { topNavigation.activate('upload'); topNavigation.setTitle('Upload'); - this._emptyView = new EmptyView(); + this._view = new PostUploadView(); + this._view.addEventListener('change', e => this._evtChange(e)); + this._view.addEventListener('submit', e => this._evtSubmit(e)); + } + + _evtChange(e) { + if (e.detail.uploadables.length) { + misc.enableExitConfirmation(); + } else { + misc.disableExitConfirmation(); + } + this._view.clearMessages(); + } + + _evtSubmit(e) { + this._view.disableForm(); + this._view.clearMessages(); + + e.detail.uploadables.reduce((promise, uploadable) => { + return promise.then( + () => { + let post = new Post(); + post.safety = uploadable.safety; + if (uploadable.url) { + post.newContentUrl = uploadable.url; + } else { + post.newContent = uploadable.file; + } + return post.save(uploadable.anonymous) + .then(() => { + this._view.removeUploadable(uploadable); + return Promise.resolve(); + }); + }); + }, Promise.resolve()).then( + () => { + misc.disableExitConfirmation(); + const ctx = router.show('/posts'); + ctx.controller.showSuccess('Posts uploaded.'); + }, errorMessage => { + this._view.showError(errorMessage); + this._view.enableForm(); + return Promise.reject(); + }); } } diff --git a/client/js/models/post.js b/client/js/models/post.js index 9c1256e..ded6b51 100644 --- a/client/js/models/post.js +++ b/client/js/models/post.js @@ -14,39 +14,41 @@ class Post extends events.EventTarget { this._updateFromResponse({}); } - 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 content() { throw 'Invalid operation'; } - get thumbnail() { throw 'Invalid operation'; } + 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 newContent() { throw 'Invalid operation'; } + get newContentUrl() { throw 'Invalid operation'; } + get newThumbnail() { throw 'Invalid operation'; } - get flags() { return this._flags; } - get tags() { return this._tags; } - get notes() { return this._notes; } - get comments() { return this._comments; } - get relations() { return this._relations; } + get flags() { return this._flags; } + 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 commentCount() { return this._commentCount; } - get favoriteCount() { return this._favoriteCount; } - get ownFavorite() { return this._ownFavorite; } - get ownScore() { return this._ownScore; } + get score() { return this._score; } + get commentCount() { return this._commentCount; } + get favoriteCount() { return this._favoriteCount; } + get ownFavorite() { return this._ownFavorite; } + get ownScore() { return this._ownScore; } get hasCustomThumbnail() { return this._hasCustomThumbnail; } - set flags(value) { this._flags = value; } - set tags(value) { this._tags = value; } - set safety(value) { this._safety = value; } - set relations(value) { this._relations = value; } - set content(value) { this._content = value; } - set thumbnail(value) { this._thumbnail = value; } + set flags(value) { this._flags = value; } + set tags(value) { this._tags = value; } + set safety(value) { this._safety = value; } + set relations(value) { this._relations = value; } + set newContent(value) { this._newContent = value; } + set newContentUrl(value) { this._newContentUrl = value; } + set newThumbnail(value) { this._newThumbnail = value; } static fromResponse(response) { const ret = new Post(); @@ -84,11 +86,14 @@ class Post extends events.EventTarget { s => s.toLowerCase() != tagName.toLowerCase()); } - save() { + save(anonymous) { const files = []; const detail = {version: this._version}; // send only changed fields to avoid user privilege violation + if (anonymous === true) { + detail.anonymous = true; + } if (this._safety !== this._orig._safety) { detail.safety = this._safety; } @@ -108,11 +113,13 @@ class Post extends events.EventTarget { text: note.text, })); } - if (this._content) { - files.content = this._content; + if (this._newContent) { + files.content = this._newContent; + } else if (this._newContentUrl) { + detail.contentUrl = this._newContentUrl; } - if (this._thumbnail !== undefined) { - files.thumbnail = this._thumbnail; + if (this._newThumbnail !== undefined) { + files.thumbnail = this._newThumbnail; } let promise = this._id ? @@ -123,11 +130,11 @@ class Post extends events.EventTarget { this._updateFromResponse(response); this.dispatchEvent( new CustomEvent('change', {detail: {post: this}})); - if (this._content) { + if (this._newContent || this._newContentUrl) { this.dispatchEvent( new CustomEvent('changeContent', {detail: {post: this}})); } - if (this._thumbnail) { + if (this._newThumbnail) { this.dispatchEvent( new CustomEvent('changeThumbnail', {detail: {post: this}})); } @@ -254,7 +261,8 @@ class Post extends events.EventTarget { _flags: [...response.flags || []], _tags: [...response.tags || []], _notes: NoteList.fromResponse([...response.notes || []]), - _comments: CommentList.fromResponse([...response.comments || []]), + _comments: CommentList.fromResponse( + [...response.comments || []]), _relations: [...response.relations || []], _score: response.score, diff --git a/client/js/util/views.js b/client/js/util/views.js index 2bd3531..e6daf51 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -487,6 +487,8 @@ module.exports = misc.arrayToObject([ makeNonVoidElement, makeTagLink, makePostLink, + makeCheckbox, + makeRadio, syncScrollPosition, slideDown, slideUp, diff --git a/client/js/views/post_upload_view.js b/client/js/views/post_upload_view.js new file mode 100644 index 0000000..5ae241d --- /dev/null +++ b/client/js/views/post_upload_view.js @@ -0,0 +1,266 @@ +'use strict'; + +const events = require('../events.js'); +const views = require('../util/views.js'); +const FileDropperControl = require('../controls/file_dropper_control.js'); + +const template = views.getTemplate('post-upload'); +const rowTemplate = views.getTemplate('post-upload-row'); + +let globalOrder = 0; + +class Uploadable extends events.EventTarget { + constructor() { + super(); + this.safety = 'safe'; + this.anonymous = false; + this.order = globalOrder; + globalOrder++; + } + + get type() { + return 'unknown'; + } + + get key() { + throw new Error('Not implemented'); + } + + get name() { + throw new Error('Not implemented'); + } +} + +class File extends Uploadable { + constructor(file) { + super(); + this.file = file; + + this._imageUrl = null; + let reader = new FileReader(); + reader.readAsDataURL(file); + reader.addEventListener('load', e => { + this._imageUrl = e.target.result; + this.dispatchEvent( + new CustomEvent('finish', {detail: {uploadable: this}})); + }); + } + + get type() { + return { + 'application/x-shockwave-flash': 'flash', + 'image/gif': 'image', + 'image/jpeg': 'image', + 'image/png': 'image', + 'video/mp4': 'video', + 'video/webm': 'video', + }[this.file.type] || 'unknown'; + } + + get imageUrl() { + return this._imageUrl; + } + + get key() { + return this.file.name + this.file.size; + } + + get name() { + return this.file.name; + } +} + +class Url extends Uploadable { + constructor(url) { + super(); + this.url = url; + this.dispatchEvent(new CustomEvent('finish')); + } + + get type() { + let extensions = { + 'swf': 'flash', + 'jpg': 'image', + 'png': 'image', + 'gif': 'image', + 'mp4': 'video', + 'webm': 'video', + }; + for (let extension of Object.keys(extensions)) { + if (this.url.toLowerCase().indexOf('.' + extension) !== -1) { + return extensions[extension]; + } + } + return 'unknown'; + } + + get imageUrl() { + return this.url; + } + + get key() { + return this.url; + } + + get name() { + return this.url; + } +} + +class PostUploadView extends events.EventTarget { + constructor() { + super(); + + this._hostNode = document.getElementById('content-holder'); + views.replaceContent(this._hostNode, template()); + views.syncScrollPosition(); + + this._uploadables = new Map(); + this._contentFileDropper = new FileDropperControl( + this._contentInputNode, + { + allowUrls: true, + allowMultiple: true, + lock: false, + }); + this._contentFileDropper.addEventListener( + 'fileadd', e => this._evtFilesAdded(e)); + this._contentFileDropper.addEventListener( + 'urladd', e => this._evtUrlsAdded(e)); + + this._formNode.addEventListener('submit', e => this._evtFormSubmit(e)); + this._formNode.classList.add('inactive'); + } + + enableForm() { + views.enableForm(this._formNode); + } + + disableForm() { + views.disableForm(this._formNode); + } + + clearMessages() { + views.clearMessages(this._hostNode); + } + + showSuccess(message) { + views.showSuccess(this._hostNode, message); + } + + showError(message) { + views.showError(this._hostNode, message); + } + + addUploadables(uploadables) { + this._formNode.classList.remove('inactive'); + let duplicatesFound = 0; + for (let uploadable of uploadables) { + if (this._uploadables.has(uploadable.key)) { + duplicatesFound++; + continue; + } + this._uploadables.set(uploadable.key, uploadable); + this._emit('change'); + this._createRowNode(uploadable); + uploadable.addEventListener( + 'finish', e => this._updateRowNode(e.detail.uploadable)); + } + if (duplicatesFound) { + let message = null; + if (duplicatesFound < uploadables.length) { + message = 'Some of the files were already added ' + + 'and have been skipped.'; + } else if (duplicatesFound === 1) { + message = 'This file was already added.'; + } else { + message = 'These files were already added.'; + } + alert(message); + } + } + + removeUploadable(uploadable) { + if (!this._uploadables.has(uploadable.key)) { + return; + } + uploadable.rowNode.parentNode.removeChild(uploadable.rowNode); + this._uploadables.delete(uploadable.key); + this._emit('change'); + if (!this._uploadables.size) { + this._formNode.classList.add('inactive'); + } + } + + _evtFilesAdded(e) { + this.addUploadables(e.detail.files.map(file => new File(file))); + } + + _evtUrlsAdded(e) { + this.addUploadables(e.detail.urls.map(url => new Url(url))); + } + + _evtFormSubmit(e) { + e.preventDefault(); + this._emit('submit'); + } + + _evtRemoveClick(e, uploadable) { + this.removeUploadable(uploadable); + } + + _evtSafetyRadioboxChange(e, uploadable) { + uploadable.safety = e.target.value; + } + + _evtAnonymityCheckboxChange(e, uploadable) { + uploadable.anonymous = e.target.checked; + } + + _emit(eventType) { + let sortedUploadables = [...this._uploadables.values()]; + sortedUploadables.sort((a, b) => a.order - b.order); + this.dispatchEvent( + new CustomEvent( + eventType, {detail: {uploadables: sortedUploadables}})); + } + + _createRowNode(uploadable) { + const rowNode = rowTemplate({uploadable: uploadable}); + this._listNode.appendChild(rowNode); + for (let radioboxNode of rowNode.querySelectorAll('.safety input')) { + radioboxNode.addEventListener( + 'change', e => this._evtSafetyRadioboxChange(e, uploadable)); + } + rowNode.querySelector('.anonymous input').addEventListener( + 'change', e => this._evtAnonymityCheckboxChange(e, uploadable)); + rowNode.querySelector('a.remove').addEventListener( + 'click', e => this._evtRemoveClick(e, uploadable)); + uploadable.rowNode = rowNode; + } + + _updateRowNode(uploadable) { + const rowNode = rowTemplate({uploadable: uploadable}); + views.replaceContent( + uploadable.rowNode.querySelector('.thumbnail'), + rowNode.querySelector('.thumbnail').childNodes); + } + + get _listNode() { + return this._hostNode.querySelector('.uploadables-container'); + } + + get _formNode() { + return this._hostNode.querySelector('form'); + } + + get _submitButtonNode() { + return this._hostNode.querySelector('form [type=submit]'); + } + + get _contentInputNode() { + return this._formNode.querySelector('.dropper-container'); + } +} + +module.exports = PostUploadView;