client/posts: tweak upload appearance and UX
This commit is contained in:
parent
4cb613a5c9
commit
d1bb33ecf0
5 changed files with 244 additions and 192 deletions
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue