client/posts: tweak upload appearance and UX

This commit is contained in:
rr- 2017-01-06 14:05:54 +01:00
parent 4cb613a5c9
commit d1bb33ecf0
5 changed files with 244 additions and 192 deletions

View File

@ -1,5 +1,6 @@
@import colors @import colors
$upload-header-background-color = $top-navigation-color
$upload-border-color = #DDD
$cancel-button-color = tomato $cancel-button-color = tomato
#post-upload #post-upload
@ -35,7 +36,7 @@ $cancel-button-color = tomato
.skip-duplicates .skip-duplicates
margin-left: 1em margin-left: 1em
.messages form>.messages
margin-top: 1em margin-top: 1em
.uploadables-container .uploadables-container
@ -43,48 +44,78 @@ $cancel-button-color = tomato
margin: 0 margin: 0
padding: 0 padding: 0
li .uploadable-container
clear: both
margin: 0 0 1.2em 0 margin: 0 0 1.2em 0
padding-left: 13em
.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
.thumbnail-wrapper .thumbnail-wrapper
float: left float: left
width: 12.5em width: 12em
height: 7em height: 8em
margin: 0.2em 1em 0 0 margin: 0 0 0 -13em
.thumbnail .thumbnail
width: 100% width: 100%
height: 100% height: 100%
.controls .uploadable
float: right border: 1px solid $upload-border-color
a min-height: 8em
color: $inactive-link-color box-sizing: border-box
margin-left: 0.5em
div:last-child:after header
display: block line-height: 1.5em
content: ' ' padding: 0.25em 1em
height: 1px text-align: left
clear: both 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

View File

@ -1,10 +1,4 @@
<li class='uploadable'> <li class='uploadable-container'>
<div class='controls'>
<a href class='move-up'><i class='fa fa-chevron-up'></i></a>
<a href class='move-down'><i class='fa fa-chevron-down'></i></a>
<a href class='remove'><i class='fa fa-remove'></i></a>
</div>
<div class='thumbnail-wrapper'> <div class='thumbnail-wrapper'>
<% if (['image'].includes(ctx.uploadable.type)) { %> <% if (['image'].includes(ctx.uploadable.type)) { %>
@ -29,40 +23,58 @@
<% } %> <% } %>
</div> </div>
<div class='file'> <div class='uploadable'>
<strong><%= ctx.uploadable.name %></strong> <header>
</div> <nav>
<ul>
<li><a href class='move-up'><i class='fa fa-chevron-up'></i></a></li>
<li><a href class='move-down'><i class='fa fa-chevron-down'></i></a></li>
</ul>
</nav>
<nav>
<ul>
<li><a href class='remove'><i class='fa fa-remove'></i></a></li>
</ul>
</nav>
<div class='safety'> <span class='filename'><%= ctx.uploadable.name %></span>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %> </header>
<%= ctx.makeRadio({
name: 'safety-' + ctx.uploadable.key,
value: safety,
text: safety[0].toUpperCase() + safety.substr(1),
selectedValue: ctx.uploadable.safety,
}) %>
<% } %>
</div>
<div class='options'> <div class='body'>
<% if (ctx.canUploadAnonymously) { %> <div class='safety'>
<div class='anonymous'> <% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<%= ctx.makeCheckbox({ <%= ctx.makeRadio({
text: 'Upload anonymously', name: 'safety-' + ctx.uploadable.key,
name: 'anonymous', value: safety,
checked: ctx.uploadable.anonymous, text: safety[0].toUpperCase() + safety.substr(1),
}) %> selectedValue: ctx.uploadable.safety,
}) %>
<% } %>
</div> </div>
<% } %>
<% if (['video'].includes(ctx.uploadable.type)) { %> <div class='options'>
<div class='loop-video'> <% if (ctx.canUploadAnonymously) { %>
<%= ctx.makeCheckbox({ <div class='anonymous'>
text: 'Loop video', <%= ctx.makeCheckbox({
name: 'loop-video', text: 'Upload anonymously',
checked: ctx.uploadable.flags.includes('loop'), name: 'anonymous',
}) %> checked: ctx.uploadable.anonymous,
}) %>
</div>
<% } %>
<% if (['video'].includes(ctx.uploadable.type)) { %>
<div class='loop-video'>
<%= ctx.makeCheckbox({
text: 'Loop video',
name: 'loop-video',
checked: ctx.uploadable.flags.includes('loop'),
}) %>
</div>
<% } %>
</div> </div>
<% } %>
<div class='messages'></div>
</div>
</div> </div>
</li> </li>

View File

@ -8,9 +8,13 @@ const Post = require('../models/post.js');
const PostUploadView = require('../views/post_upload_view.js'); const PostUploadView = require('../views/post_upload_view.js');
const EmptyView = require('../views/empty_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 { class PostUploadController {
constructor() { constructor() {
this._lastPromise = null; this._lastCancellablePromise = null;
if (!api.hasPrivilege('posts:create')) { if (!api.hasPrivilege('posts:create')) {
this._view = new EmptyView(); this._view = new EmptyView();
@ -22,6 +26,7 @@ class PostUploadController {
topNavigation.setTitle('Upload'); topNavigation.setTitle('Upload');
this._view = new PostUploadView({ this._view = new PostUploadView({
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'), canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
canViewPosts: api.hasPrivilege('posts:view'),
}); });
this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtSubmit(e)); this._view.addEventListener('submit', e => this._evtSubmit(e));
@ -33,13 +38,13 @@ class PostUploadController {
misc.enableExitConfirmation(); misc.enableExitConfirmation();
} else { } else {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
this._view.clearMessages();
} }
this._view.clearMessages();
} }
_evtCancel(e) { _evtCancel(e) {
if (this._lastPromise) { if (this._lastCancellablePromise) {
this._lastPromise.abort(); this._lastCancellablePromise.abort();
} }
} }
@ -47,46 +52,57 @@ class PostUploadController {
this._view.disableForm(); this._view.disableForm();
this._view.clearMessages(); this._view.clearMessages();
e.detail.uploadables.reduce((promise, uploadable) => { e.detail.uploadables.reduce(
return promise.then(() => { (promise, uploadable) =>
let post = new Post(); promise.then(() =>
post.safety = uploadable.safety; this._uploadSinglePost(
post.flags = uploadable.flags; uploadable, e.detail.skipDuplicates)),
if (uploadable.url) { Promise.resolve())
post.newContentUrl = uploadable.url; .then(() => {
} else { this._view.clearMessages();
post.newContent = uploadable.file; 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); _uploadSinglePost(uploadable, skipDuplicates) {
this._lastPromise = modelPromise; let post = new Post();
post.safety = uploadable.safety;
post.flags = uploadable.flags;
return modelPromise if (uploadable.url) {
.then(() => { post.newContentUrl = uploadable.url;
this._view.removeUploadable(uploadable); } else {
return Promise.resolve(); post.newContent = uploadable.file;
}).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())
let savePromise = post.save(uploadable.anonymous)
.then(() => { .then(() => {
misc.disableExitConfirmation(); this._view.removeUploadable(uploadable);
const ctx = router.show('/posts'); return Promise.resolve();
ctx.controller.showSuccess('Posts uploaded.');
}, errorMessage => { }, errorMessage => {
this._view.showError(errorMessage); // XXX:
this._view.enableForm(); // lame, API eats error codes so we need to match
return Promise.reject(); // messages instead
if (skipDuplicates &&
errorMessage.match(/already uploaded/)) {
return Promise.resolve();
}
return Promise.reject([errorMessage, uploadable, null]);
}); });
this._lastCancellablePromise = savePromise;
return savePromise;
} }
} }

View File

@ -267,25 +267,26 @@ function showMessage(target, message, className) {
if (!message) { if (!message) {
message = 'Unknown message'; message = 'Unknown message';
} }
const messagesHolder = target.querySelector('.messages'); const messagesHolderNode = target.querySelector('.messages');
if (!messagesHolder) { if (!messagesHolderNode) {
return false; return false;
} }
/* TODO: animate this */ const textNode = document.createElement('div');
const node = document.createElement('div'); textNode.innerHTML = message.replace(/\n/g, '<br/>');
node.innerHTML = message.replace(/\n/g, '<br/>'); textNode.classList.add('message');
node.classList.add('message'); textNode.classList.add(className);
node.classList.add(className); const wrapperNode = document.createElement('div');
const wrapper = document.createElement('div'); wrapperNode.classList.add('message-wrapper');
wrapper.classList.add('message-wrapper'); wrapperNode.appendChild(textNode);
wrapper.appendChild(node); messagesHolderNode.appendChild(wrapperNode);
messagesHolder.appendChild(wrapper);
return true; return true;
} }
function showError(target, message) { function showError(target, message) {
document.oldTitle = document.title; if (!document.title.startsWith('!')) {
document.title = `! ${document.title}`; document.oldTitle = document.title;
document.title = `! ${document.title}`;
}
return showMessage(target, misc.formatInlineMarkdown(message), 'error'); return showMessage(target, misc.formatInlineMarkdown(message), 'error');
} }
@ -302,9 +303,9 @@ function clearMessages(target) {
document.title = document.oldTitle; document.title = document.oldTitle;
document.oldTitle = null; document.oldTitle = null;
} }
const messagesHolder = target.querySelector('.messages'); for (let messagesHolderNode of target.querySelectorAll('.messages')) {
/* TODO: animate that */ emptyContent(messagesHolderNode);
emptyContent(messagesHolder); }
} }
function htmlToDom(html) { function htmlToDom(html) {

View File

@ -20,12 +20,6 @@ function _mimeTypeToPostType(mimeType) {
}[mimeType] || 'unknown'; }[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 { class Uploadable extends events.EventTarget {
constructor() { constructor() {
super(); super();
@ -193,8 +187,16 @@ class PostUploadView extends events.EventTarget {
views.showSuccess(this._hostNode, message); views.showSuccess(this._hostNode, message);
} }
showError(message) { showError(message, uploadable) {
views.showError(this._hostNode, message); 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) { addUploadables(uploadables) {
@ -207,9 +209,9 @@ class PostUploadView extends events.EventTarget {
} }
this._uploadables.set(uploadable.key, uploadable); this._uploadables.set(uploadable.key, uploadable);
this._emit('change'); this._emit('change');
this._createRowNode(uploadable); this._renderRowNode(uploadable);
uploadable.addEventListener( uploadable.addEventListener(
'finish', e => this._updateRowNode(e.detail.uploadable)); 'finish', e => this._updateThumbnailNode(e.detail.uploadable));
} }
if (duplicatesFound) { if (duplicatesFound) {
let message = null; let message = null;
@ -236,6 +238,7 @@ class PostUploadView extends events.EventTarget {
this._emit('change'); this._emit('change');
if (!this._uploadables.size) { if (!this._uploadables.size) {
this._formNode.classList.add('inactive'); this._formNode.classList.add('inactive');
this._submitButtonNode.value = 'Upload all';
} }
} }
@ -254,9 +257,24 @@ class PostUploadView extends events.EventTarget {
_evtFormSubmit(e) { _evtFormSubmit(e) {
e.preventDefault(); e.preventDefault();
for (let uploadable of this._uploadables.values()) {
this._updateUploadableFromDom(uploadable);
}
this._submitButtonNode.value = 'Resume upload';
this._emit('submit'); 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) { _evtRemoveClick(e, uploadable) {
e.preventDefault(); e.preventDefault();
if (this._uploading) { if (this._uploading) {
@ -265,48 +283,23 @@ class PostUploadView extends events.EventTarget {
this.removeUploadable(uploadable); this.removeUploadable(uploadable);
} }
_evtMoveUpClick(e, uploadable) { _evtMoveClick(e, uploadable, delta) {
e.preventDefault(); e.preventDefault();
if (this._uploading) { if (this._uploading) {
return; return;
} }
let sortedUploadables = this._getSortedUploadables(); let sortedUploadables = this._getSortedUploadables();
if (uploadable.order > 0) { if ((uploadable.order + delta).between(-1, sortedUploadables.length)) {
uploadable.order--; uploadable.order += delta;
const prevUploadable = sortedUploadables[uploadable.order]; const otherUploadable = sortedUploadables[uploadable.order];
prevUploadable.order++; otherUploadable.order -= delta;
uploadable.rowNode.parentNode.insertBefore( if (delta === 1) {
uploadable.rowNode, prevUploadable.rowNode); uploadable.rowNode.parentNode.insertBefore(
} otherUploadable.rowNode, uploadable.rowNode);
} } else {
uploadable.rowNode.parentNode.insertBefore(
_evtMoveDownClick(e, uploadable) { uploadable.rowNode, otherUploadable.rowNode);
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');
} }
} }
@ -333,28 +326,27 @@ class PostUploadView extends events.EventTarget {
}})); }}));
} }
_createRowNode(uploadable) { _renderRowNode(uploadable) {
const rowNode = rowTemplate(Object.assign( const rowNode = rowTemplate(Object.assign(
{}, this._ctx, {uploadable: uploadable})); {}, 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; 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( const rowNode = rowTemplate(Object.assign(
{}, this._ctx, {uploadable: uploadable})); {}, this._ctx, {uploadable: uploadable}));
views.replaceContent( views.replaceContent(