This commit is contained in:
Christoph Wiechert
2017-04-23 13:41:29 +02:00
commit 30d6918b83
42 changed files with 2018 additions and 0 deletions

74
app/src/Upload/Files.vue Normal file
View File

@@ -0,0 +1,74 @@
<template lang="pug">
div
.panel.panel-default(:class="{'panel-primary': !disabled}")
.panel-heading Files
.panel-body
.dropHint(:style="{cursor: disabled ? 'default' : 'pointer'}",
onclick="document.getElementById('fileInput').click();",
v-show="files.length === 0")
i.fa.fa-plus.fa-4x
br
| Drop your files here
table.table.table-striped
tbody
tr(v-for="file in files")
td(style="width: 60px")
file-icon(:file="file._File")
td
p
strong {{ file.name }}
small ({{ file.humanSize }})
p
input.form-control.input-sm(type="text", placeholder="comment...", v-model="file.comment", :disabled="disabled")
.alert.alert-danger(v-if="file.error")
i.fa.fa-fw.fa-exclamation-triangle
| {{ file.error }}
.progress(v-show="!file.error && (state === 'uploading' || state === 'uploaded')", style="height:7px")
.progress-bar.progress-bar-success.progress-bar-striped(:style="{width: file.progress.percentage+'%'}",:class="{active:!file.uploaded}")
td(style="width: 33px;")
a.btn.btn-warning.btn-sm(@click="!disabled && $store.commit('upload/REMOVE_FILE', file)", :disabled="disabled")
i.fa.fa-trash.pull-right(style="display: inline-block; margin-left: auto; margin-right: auto;")
input#fileInput(type="file", @change="$store.dispatch('upload/addFiles', $event.target.files)", multiple="", :disabled="disabled", style="display: none")
.text-right
a.btn.btn-success.btn-sm(onclick="document.getElementById('fileInput').click();", :disabled="disabled", v-show="files.length>0")
i.fa.fa-plus-circle.fa-fw
</template>
<script type="text/babel">
"use strict";
import {mapState} from 'vuex';
import dragDrop from 'drag-drop';
import FileIcon from '../common/FileIcon.vue';
export default {
name: 'Files',
components: { FileIcon },
computed: mapState({
disabled: 'disabled',
state: 'state',
files: state => state.upload.files
}),
mounted() {
// init drop files support on <body>
dragDrop('body', files => this.$store.dispatch('upload/addFiles', files));
}
};
</script>
<style>
.dropHint {
text-align: center;
padding: 19px 0;
}
.dropHint i {
color: #337AB7;
}
.dropHint:hover i {
color: #286090;
}
</style>

View File

@@ -0,0 +1,63 @@
<template lang="pug">
div(v-if="config && config.retentions")
.panel.panel-default(:class="{'panel-info': !disabled}")
.panel-heading Settings
.panel-body
.form-group
label(for='retention') Retention
|
select#retention.form-control(:value='retention', :disabled='disabled',
@input="$store.commit('upload/RETENTION', $event.target.value)")
option(v-for='(label, seconds, index) in config.retentions',
:value="seconds", :selected="seconds == retention") {{ label }}
div
label(for='password') Password
.input-group
input#password.form-control(type='text', :value='password',
@input="$store.commit('upload/PASSWORD', $event.target.value)",
:disabled='disabled', placeholder='optional')
span.input-group-addon(style='cursor: pointer', title='generate password', @click='generatePassword()')
i.fa.fa-key
</template>
<script type="text/babel">
"use strict";
import {mapState} from 'vuex';
const passGen = {
_pattern : /[A-Z0-9_\-\+\!]/,
_getRandomByte: function() {
const result = new Uint8Array(1);
window.crypto.getRandomValues(result);
return result[0];
},
generate: function(length) {
if(!window.crypto || !window.crypto.getRandomValues) return '';
return Array.apply(null, {'length': length}).map(function() {
let result;
while(true) {
result = String.fromCharCode(this._getRandomByte());
if(this._pattern.test(result)) return result;
}
}, this).join('');
}
};
export default {
name: 'Settings',
computed: mapState({
config: 'config',
disabled: 'disabled',
retention: state => state.upload.retention,
password: state => state.upload.password,
}),
methods: {
generatePassword() {
if(this.disabled) return;
this.$store.commit('upload/PASSWORD', passGen.generate(10));
}
}
};
</script>

41
app/src/Upload/store.js Normal file
View File

@@ -0,0 +1,41 @@
'use strict';
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
import config from './store/config.js';
import upload from './store/upload.js';
export default new Vuex.Store({
modules: {
config,
upload
},
state: {
error: '',
// disable all input fields
disabled: false,
/* States:
* new: can modify settings and add/remove files
* uploading: probably let user pause/cancel upload
* uploaded: show download link
* uploadError: show retry btn */
state: 'new'
},
mutations: {
ERROR(state, msg) {
state.error = msg;
state.disabled = true;
},
DISABLE(state) {
state.disabled = true;
},
STATE(state, val) {
state.state = val;
if(val !== 'new') state.disabled = true;
},
},
});

View File

@@ -0,0 +1,39 @@
'use strict';
import Vue from 'vue';
export default {
namespaced: true,
state: {},
mutations: {
SET(state, val) {
for (let k in val) {
Vue.set(state, k, val[k]);
}
}
},
actions: {
fetch({commit, }) {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/config.json');
xhr.onload = () => {
if(xhr.status === 200) {
try {
const conf = JSON.parse(xhr.responseText);
commit('SET', conf);
commit('upload/RETENTION', conf.defaultRetention, {root:true});
}
catch(e) {
commit('ERROR', `Config parse Error: ${e.message}`, {root: true});
}
}
else {
commit('ERROR', `Config load error: ${xhr.status} ${xhr.statusText}`, {root: true});
}
};
xhr.send();
}
}
}

View File

@@ -0,0 +1,184 @@
'use strict';
import tus from "tus-js-client";
import uuid from 'uuid/v4';
import md5 from 'crypto-js/md5';
function humanFileSize(fileSizeInBytes) {
let i = -1;
const byteUnits = [' kB', ' MB', ' GB', ' TB', 'PB', 'EB', 'ZB', 'YB'];
do {
fileSizeInBytes = fileSizeInBytes / 1024;
i++;
}
while(fileSizeInBytes > 1024);
return Math.max(fileSizeInBytes, 0.01).toFixed(2) + byteUnits[i];
}
let onOnlineHandler = null;
let onOnlineHandlerAttached = false;
export default {
namespaced: true,
state: {
retention: null,
password: '',
files: [],
sid: md5(uuid()).toString().substr(0, 12),
bytesUploaded: 0,
},
getters: {
shareUrl: state => {
return document.location.protocol + '//' + document.location.host + '/' + state.sid;
},
percentUploaded: state => {
return Math.min(
Math.round(state.files.reduce((sum, file) => sum += file.progress.percentage, 0) / state.files.length), 100);
}
},
mutations: {
RETENTION(state, seconds) {
state.retention = seconds;
},
PASSWORD(state, pwd) {
state.password = pwd;
},
ADD_FILE(state, file) {
state.files.splice(0, 0, file);
},
REMOVE_FILE(state, file) {
let index = state.files.indexOf(file);
if(index > -1) state.files.splice(index, 1);
},
UPDATE_FILE(state, payload) {
for(let k in payload.data) {
payload.file[k] = payload.data[k];
}
},
NEW_SESSION(state) {
state.password = '';
state.files.splice(0, state.files.length);
state.sid = md5(uuid()).toString().substr(0, 12);
}
},
actions: {
addFiles({commit, state}, files) {
if(state.disabled) return;
for(let i = 0; i < files.length; i++) {
// wrap, don't change the HTML5-File-API object
commit('ADD_FILE', {
_File: files[i],
name: files[i].name,
comment: '',
progress: {percentage: 0, humanSize: 0},
uploaded: false,
error: false,
humanSize: humanFileSize(files[i].size),
_retryDelay: 500,
_retries: 0
});
}
},
upload({commit, dispatch, state}) {
commit('STATE', 'uploading', {root:true});
commit('ERROR', '', {root:true});
if(onOnlineHandler === null) {
onOnlineHandler = function() {
onOnlineHandlerAttached = false;
commit('ERROR', false, {root: true});
dispatch('upload');
}
}
if(onOnlineHandlerAttached) window.removeEventListener('online', onOnlineHandler);
// upload all files in parallel
state.files.forEach(file => {
file.error = '';
file._retries = 0;
file._retryDelay = 500;
const _File = file._File;
let tusUploader = new tus.Upload(_File, {
metadata: {
sid: state.sid,
retention: state.retention,
password: state.password,
name: file.name,
comment: file.comment,
type: file._File.type
},
resume: true,
endpoint: "/files/",
fingerprint: (file) => {
// include sid to prevent duplicate file detection on different session
return ["tus", state.sid, file.name, file.type, file.size, file.lastModified].join("-");
},
retryDelays: null,
onError(error) {
// browser is offline
if(!navigator.onLine) {
commit('ERROR', 'You are offline. Your uploads will resume as soon as you are back online.', {root: true});
if(!onOnlineHandlerAttached) {
onOnlineHandlerAttached = true;
// attach onOnline handler
window.addEventListener('online', onOnlineHandler);
}
}
// Client Error
else if(error && error.originalRequest &&
error.originalRequest.status >= 400 && error.originalRequest.status < 500)
{
commit('UPDATE_FILE', {file, data: {error: error.message || error.toString()}});
}
// Generic Error
else {
if(file._retries > 30) {
commit('UPDATE_FILE', {file, data: {error: error.message || error.toString()}});
if(state.files.every(f => f.error)) {
commit('STATE', 'uploadError', {root: true});
commit('ERROR', 'Upload failed.', {root: true});
}
return;
}
file._retryDelay = Math.min(file._retryDelay*1.7, 10000);
file._retries++;
if(console) console.log(error.message || error.toString(), '; will retry in', file._retryDelay, 'ms');
setTimeout(() => tusUploader.start(), file._retryDelay);
}
},
onProgress(bytesUploaded, bytesTotal) {
// uploaded=total gets also emitted on error
if(bytesUploaded === bytesTotal) return;
file.error = '';
file._retries = 0;
file._retryDelay = 500;
const percentage = Math.round(bytesUploaded / bytesTotal * 10000) / 100;
commit('UPDATE_FILE', {
file,
data: {progress: {percentage, humanSize: humanFileSize(bytesUploaded)}}
});
},
onSuccess() {
localStorage.removeItem(tusUploader._fingerprint);
commit('UPDATE_FILE', {file, data: {
uploaded:true,
progress: {percentage: 100, humanFileSize: file.humanSize}
}});
if(state.files.every(f => f.uploaded)) commit('STATE', 'uploaded', {root: true});
}
});
tusUploader.start();
});
}
}
}