From d1bb33ecf0e82689ecbca747a8ac7b038d6cf173 Mon Sep 17 00:00:00 2001 From: rr- Date: Fri, 6 Jan 2017 14:05:54 +0100 Subject: [PATCH] client/posts: tweak upload appearance and UX --- client/css/post-upload.styl | 107 ++++++++++------ client/html/post_upload_row.tpl | 86 +++++++------ .../js/controllers/post_upload_controller.js | 92 ++++++++------ client/js/util/views.js | 33 ++--- client/js/views/post_upload_view.js | 118 ++++++++---------- 5 files changed, 244 insertions(+), 192 deletions(-) diff --git a/client/css/post-upload.styl b/client/css/post-upload.styl index 28a9770b..9465a871 100644 --- a/client/css/post-upload.styl +++ b/client/css/post-upload.styl @@ -1,5 +1,6 @@ @import colors - +$upload-header-background-color = $top-navigation-color +$upload-border-color = #DDD $cancel-button-color = tomato #post-upload @@ -35,7 +36,7 @@ $cancel-button-color = tomato .skip-duplicates margin-left: 1em - .messages + form>.messages margin-top: 1em .uploadables-container @@ -43,48 +44,78 @@ $cancel-button-color = tomato margin: 0 padding: 0 - li + .uploadable-container + clear: both margin: 0 0 1.2em 0 - - .uploadable - .file - margin: 0.3em 0 - overflow: hidden - white-space: nowrap - text-align: left - text-overflow: ellipsis - - .anonymous - margin: 0.3em 0 - - .safety - margin: 0.3em 0 - label - display: inline-block - margin-right: 1em - - .options div - display: inline-block - margin: 0 1em 0 0 + padding-left: 13em .thumbnail-wrapper float: left - width: 12.5em - height: 7em - margin: 0.2em 1em 0 0 - + width: 12em + height: 8em + margin: 0 0 0 -13em .thumbnail width: 100% height: 100% - .controls - float: right - a - color: $inactive-link-color - margin-left: 0.5em + .uploadable + border: 1px solid $upload-border-color + min-height: 8em + box-sizing: border-box - div:last-child:after - display: block - content: ' ' - height: 1px - clear: both + header + line-height: 1.5em + padding: 0.25em 1em + text-align: left + background: $upload-header-background-color + border-bottom: 1px solid $upload-border-color + + nav + &:first-of-type + float: left + a + margin: 0 0.5em 0 0 + &:last-of-type + float: right + a + margin: 0 0 0 0.5em + + ul + list-style-type: none + ul, li + display: inline-block + margin: 0 + padding: 0 + + span.filename + padding: 0 0.5em + display: block + overflow: hidden + white-space: nowrap + text-overflow: ellipsis + + .body + margin: 1em + + .anonymous + margin: 0.3em 0 + + .safety + margin: 0.3em 0 + label + display: inline-block + margin-right: 1em + + .options div + display: inline-block + margin: 0 1em 0 0 + + .messages + margin-top: 1em + .message:last-child + margin-bottom: 0 + + &:first-child .move-up + color: $inactive-link-color + &:last-child .move-down + color: $inactive-link-color diff --git a/client/html/post_upload_row.tpl b/client/html/post_upload_row.tpl index d91f274f..a8a04db6 100644 --- a/client/html/post_upload_row.tpl +++ b/client/html/post_upload_row.tpl @@ -1,10 +1,4 @@ -
  • -
    - - - -
    - +
  • <% if (['image'].includes(ctx.uploadable.type)) { %> @@ -29,40 +23,58 @@ <% } %>
    -
    - <%= 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.uploadable.name %> +
    -
    - <% if (ctx.canUploadAnonymously) { %> -
    - <%= ctx.makeCheckbox({ - text: 'Upload anonymously', - name: 'anonymous', - checked: ctx.uploadable.anonymous, - }) %> +
    +
    + <% 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, + }) %> + <% } %>
    - <% } %> - <% if (['video'].includes(ctx.uploadable.type)) { %> -
    - <%= ctx.makeCheckbox({ - text: 'Loop video', - name: 'loop-video', - checked: ctx.uploadable.flags.includes('loop'), - }) %> +
    + <% if (ctx.canUploadAnonymously) { %> +
    + <%= ctx.makeCheckbox({ + text: 'Upload anonymously', + name: 'anonymous', + checked: ctx.uploadable.anonymous, + }) %> +
    + <% } %> + + <% if (['video'].includes(ctx.uploadable.type)) { %> +
    + <%= ctx.makeCheckbox({ + text: 'Loop video', + name: 'loop-video', + checked: ctx.uploadable.flags.includes('loop'), + }) %> +
    + <% } %>
    - <% } %> + +
    +
  • diff --git a/client/js/controllers/post_upload_controller.js b/client/js/controllers/post_upload_controller.js index 02b45cf1..9cf3d4b5 100644 --- a/client/js/controllers/post_upload_controller.js +++ b/client/js/controllers/post_upload_controller.js @@ -8,9 +8,13 @@ const Post = require('../models/post.js'); const PostUploadView = require('../views/post_upload_view.js'); const EmptyView = require('../views/empty_view.js'); +const genericErrorMessage = + 'One of the posts needs your attention; ' + + 'click "resume upload" when you\'re ready.'; + class PostUploadController { constructor() { - this._lastPromise = null; + this._lastCancellablePromise = null; if (!api.hasPrivilege('posts:create')) { this._view = new EmptyView(); @@ -22,6 +26,7 @@ class PostUploadController { topNavigation.setTitle('Upload'); this._view = new PostUploadView({ canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'), + canViewPosts: api.hasPrivilege('posts:view'), }); this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener('submit', e => this._evtSubmit(e)); @@ -33,13 +38,13 @@ class PostUploadController { misc.enableExitConfirmation(); } else { misc.disableExitConfirmation(); + this._view.clearMessages(); } - this._view.clearMessages(); } _evtCancel(e) { - if (this._lastPromise) { - this._lastPromise.abort(); + if (this._lastCancellablePromise) { + this._lastCancellablePromise.abort(); } } @@ -47,46 +52,57 @@ class PostUploadController { this._view.disableForm(); this._view.clearMessages(); - e.detail.uploadables.reduce((promise, uploadable) => { - return promise.then(() => { - let post = new Post(); - post.safety = uploadable.safety; - post.flags = uploadable.flags; - if (uploadable.url) { - post.newContentUrl = uploadable.url; - } else { - post.newContent = uploadable.file; - } + e.detail.uploadables.reduce( + (promise, uploadable) => + promise.then(() => + this._uploadSinglePost( + uploadable, e.detail.skipDuplicates)), + Promise.resolve()) + .then(() => { + this._view.clearMessages(); + misc.disableExitConfirmation(); + const ctx = router.show('/posts'); + ctx.controller.showSuccess('Posts uploaded.'); + }, errorContext => { + if (errorContext.constructor === Array) { + const [errorMessage, uploadable] = errorContext; + this._view.showError(genericErrorMessage); + this._view.showError(errorMessage, uploadable); + } else { + this._view.showError(errorContext); + } + this._view.enableForm(); + return Promise.reject(); + }); + } - let modelPromise = post.save(uploadable.anonymous); - this._lastPromise = modelPromise; + _uploadSinglePost(uploadable, skipDuplicates) { + let post = new Post(); + post.safety = uploadable.safety; + post.flags = uploadable.flags; - return modelPromise - .then(() => { - this._view.removeUploadable(uploadable); - return Promise.resolve(); - }).catch(errorMessage => { - // XXX: - // lame, API eats error codes so we need to match - // messages instead - if (e.detail.skipDuplicates && - errorMessage.match(/already uploaded/)) { - return Promise.resolve(); - } - return Promise.reject(errorMessage); - }); - }); - }, Promise.resolve()) + if (uploadable.url) { + post.newContentUrl = uploadable.url; + } else { + post.newContent = uploadable.file; + } + let savePromise = post.save(uploadable.anonymous) .then(() => { - misc.disableExitConfirmation(); - const ctx = router.show('/posts'); - ctx.controller.showSuccess('Posts uploaded.'); + this._view.removeUploadable(uploadable); + return Promise.resolve(); }, errorMessage => { - this._view.showError(errorMessage); - this._view.enableForm(); - return Promise.reject(); + // XXX: + // lame, API eats error codes so we need to match + // messages instead + if (skipDuplicates && + errorMessage.match(/already uploaded/)) { + return Promise.resolve(); + } + return Promise.reject([errorMessage, uploadable, null]); }); + this._lastCancellablePromise = savePromise; + return savePromise; } } diff --git a/client/js/util/views.js b/client/js/util/views.js index 9febc7fe..a70ec690 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -267,25 +267,26 @@ function showMessage(target, message, className) { if (!message) { message = 'Unknown message'; } - const messagesHolder = target.querySelector('.messages'); - if (!messagesHolder) { + const messagesHolderNode = target.querySelector('.messages'); + if (!messagesHolderNode) { return false; } - /* TODO: animate this */ - const node = document.createElement('div'); - node.innerHTML = message.replace(/\n/g, '
    '); - node.classList.add('message'); - node.classList.add(className); - const wrapper = document.createElement('div'); - wrapper.classList.add('message-wrapper'); - wrapper.appendChild(node); - messagesHolder.appendChild(wrapper); + const textNode = document.createElement('div'); + textNode.innerHTML = message.replace(/\n/g, '
    '); + textNode.classList.add('message'); + textNode.classList.add(className); + const wrapperNode = document.createElement('div'); + wrapperNode.classList.add('message-wrapper'); + wrapperNode.appendChild(textNode); + messagesHolderNode.appendChild(wrapperNode); return true; } function showError(target, message) { - document.oldTitle = document.title; - document.title = `! ${document.title}`; + if (!document.title.startsWith('!')) { + document.oldTitle = document.title; + document.title = `! ${document.title}`; + } return showMessage(target, misc.formatInlineMarkdown(message), 'error'); } @@ -302,9 +303,9 @@ function clearMessages(target) { document.title = document.oldTitle; document.oldTitle = null; } - const messagesHolder = target.querySelector('.messages'); - /* TODO: animate that */ - emptyContent(messagesHolder); + for (let messagesHolderNode of target.querySelectorAll('.messages')) { + emptyContent(messagesHolderNode); + } } function htmlToDom(html) { diff --git a/client/js/views/post_upload_view.js b/client/js/views/post_upload_view.js index 51a035c2..185f3dde 100644 --- a/client/js/views/post_upload_view.js +++ b/client/js/views/post_upload_view.js @@ -20,12 +20,6 @@ function _mimeTypeToPostType(mimeType) { }[mimeType] || 'unknown'; } -function _listen(rootNode, selector, eventType, handler) { - for (let node of rootNode.querySelectorAll(selector)) { - node.addEventListener(eventType, e => handler(e)); - } -} - class Uploadable extends events.EventTarget { constructor() { super(); @@ -193,8 +187,16 @@ class PostUploadView extends events.EventTarget { views.showSuccess(this._hostNode, message); } - showError(message) { - views.showError(this._hostNode, message); + showError(message, uploadable) { + this._showMessage(views.showError, message, uploadable); + } + + showInfo(message, uploadable) { + this._showMessage(views.showInfo, message, uploadable); + } + + _showMessage(functor, message, uploadable) { + functor(uploadable ? uploadable.rowNode : this._hostNode, message); } addUploadables(uploadables) { @@ -207,9 +209,9 @@ class PostUploadView extends events.EventTarget { } this._uploadables.set(uploadable.key, uploadable); this._emit('change'); - this._createRowNode(uploadable); + this._renderRowNode(uploadable); uploadable.addEventListener( - 'finish', e => this._updateRowNode(e.detail.uploadable)); + 'finish', e => this._updateThumbnailNode(e.detail.uploadable)); } if (duplicatesFound) { let message = null; @@ -236,6 +238,7 @@ class PostUploadView extends events.EventTarget { this._emit('change'); if (!this._uploadables.size) { this._formNode.classList.add('inactive'); + this._submitButtonNode.value = 'Upload all'; } } @@ -254,9 +257,24 @@ class PostUploadView extends events.EventTarget { _evtFormSubmit(e) { e.preventDefault(); + for (let uploadable of this._uploadables.values()) { + this._updateUploadableFromDom(uploadable); + } + this._submitButtonNode.value = 'Resume upload'; this._emit('submit'); } + _updateUploadableFromDom(uploadable) { + const rowNode = uploadable.rowNode; + uploadable.safety = + rowNode.querySelector('.safety input:checked').value; + uploadable.anonymous = + rowNode.querySelector('.anonymous input').checked; + if (rowNode.querySelector('.loop-video input:checked')) { + uploadable.flags.push('loop'); + } + } + _evtRemoveClick(e, uploadable) { e.preventDefault(); if (this._uploading) { @@ -265,48 +283,23 @@ class PostUploadView extends events.EventTarget { this.removeUploadable(uploadable); } - _evtMoveUpClick(e, uploadable) { + _evtMoveClick(e, uploadable, delta) { e.preventDefault(); if (this._uploading) { return; } let sortedUploadables = this._getSortedUploadables(); - if (uploadable.order > 0) { - uploadable.order--; - const prevUploadable = sortedUploadables[uploadable.order]; - prevUploadable.order++; - uploadable.rowNode.parentNode.insertBefore( - uploadable.rowNode, prevUploadable.rowNode); - } - } - - _evtMoveDownClick(e, uploadable) { - e.preventDefault(); - if (this._uploading) { - return; - } - let sortedUploadables = this._getSortedUploadables(); - if (uploadable.order + 1 < sortedUploadables.length) { - uploadable.order++; - const nextUploadable = sortedUploadables[uploadable.order]; - nextUploadable.order--; - uploadable.rowNode.parentNode.insertBefore( - nextUploadable.rowNode, uploadable.rowNode); - } - } - - _evtSafetyRadioboxChange(e, uploadable) { - uploadable.safety = e.target.value; - } - - _evtAnonymityCheckboxChange(e, uploadable) { - uploadable.anonymous = e.target.checked; - } - - _evtLoopVideoCheckboxChange(e, uploadable) { - uploadable.flags = uploadable.flags.filter(f => f !== 'loop'); - if (e.target.checked) { - uploadable.flags.push('loop'); + if ((uploadable.order + delta).between(-1, sortedUploadables.length)) { + uploadable.order += delta; + const otherUploadable = sortedUploadables[uploadable.order]; + otherUploadable.order -= delta; + if (delta === 1) { + uploadable.rowNode.parentNode.insertBefore( + otherUploadable.rowNode, uploadable.rowNode); + } else { + uploadable.rowNode.parentNode.insertBefore( + uploadable.rowNode, otherUploadable.rowNode); + } } } @@ -333,28 +326,27 @@ class PostUploadView extends events.EventTarget { }})); } - _createRowNode(uploadable) { + _renderRowNode(uploadable) { const rowNode = rowTemplate(Object.assign( {}, this._ctx, {uploadable: uploadable})); - this._listNode.appendChild(rowNode); + if (uploadable.rowNode) { + uploadable.rowNode.parentNode.replaceChild( + rowNode, uploadable.rowNode); + } else { + this._listNode.appendChild(rowNode); + } - _listen(rowNode, '.safety input', 'change', - e => this._evtSafetyRadioboxChange(e, uploadable)); - _listen(rowNode, '.anonymous input', 'change', - e => this._evtAnonymityCheckboxChange(e, uploadable)); - _listen(rowNode, '.loop-video input', 'change', - e => this._evtLoopVideoCheckboxChange(e, uploadable)); - - _listen(rowNode, 'a.remove', 'click', - e => this._evtRemoveClick(e, uploadable)); - _listen(rowNode, 'a.move-up', 'click', - e => this._evtMoveUpClick(e, uploadable)); - _listen(rowNode, 'a.move-down', 'click', - e => this._evtMoveDownClick(e, uploadable)); uploadable.rowNode = rowNode; + + rowNode.querySelector('a.remove').addEventListener('click', + e => this._evtRemoveClick(e, uploadable)); + rowNode.querySelector('a.move-up').addEventListener('click', + e => this._evtMoveClick(e, uploadable, -1)); + rowNode.querySelector('a.move-down').addEventListener('click', + e => this._evtMoveClick(e, uploadable, 1)); } - _updateRowNode(uploadable) { + _updateThumbnailNode(uploadable) { const rowNode = rowTemplate(Object.assign( {}, this._ctx, {uploadable: uploadable})); views.replaceContent(