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
$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

View file

@ -1,10 +1,4 @@
<li class='uploadable'>
<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>
<li class='uploadable-container'>
<div class='thumbnail-wrapper'>
<% if (['image'].includes(ctx.uploadable.type)) { %>
@ -29,40 +23,58 @@
<% } %>
</div>
<div class='file'>
<strong><%= ctx.uploadable.name %></strong>
</div>
<div class='uploadable'>
<header>
<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'>
<% 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,
}) %>
<% } %>
</div>
<span class='filename'><%= ctx.uploadable.name %></span>
</header>
<div class='options'>
<% if (ctx.canUploadAnonymously) { %>
<div class='anonymous'>
<%= ctx.makeCheckbox({
text: 'Upload anonymously',
name: 'anonymous',
checked: ctx.uploadable.anonymous,
}) %>
<div class='body'>
<div class='safety'>
<% 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,
}) %>
<% } %>
</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 class='options'>
<% if (ctx.canUploadAnonymously) { %>
<div class='anonymous'>
<%= ctx.makeCheckbox({
text: 'Upload anonymously',
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 class='messages'></div>
</div>
</div>
</li>

View file

@ -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;
}
}

View file

@ -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, '<br/>');
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, '<br/>');
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) {

View file

@ -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(