init
This commit is contained in:
15
app/.babelrc
Normal file
15
app/.babelrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"env",
|
||||
{
|
||||
"modules": false
|
||||
}
|
||||
],
|
||||
"stage-2"
|
||||
],
|
||||
"plugins": [
|
||||
"transform-runtime"
|
||||
],
|
||||
"comments": false
|
||||
}
|
||||
4
app/.gitignore
vendored
Normal file
4
app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
npm-debug.log
|
||||
14
app/README.md
Normal file
14
app/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# PsiTransfer Upload / Download App
|
||||
|
||||
## Build Setup
|
||||
|
||||
``` bash
|
||||
# install dependencies
|
||||
npm install
|
||||
|
||||
# serve with hot reload at localhost:8080
|
||||
npm run dev
|
||||
|
||||
# build for production with minification
|
||||
npm run build
|
||||
```
|
||||
35
app/package.json
Normal file
35
app/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "psitransfer",
|
||||
"description": "A Vue.js project",
|
||||
"version": "1.0.0",
|
||||
"author": "Christoph Wiechert <wio@psitrax.de>",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development webpack-dev-server --inline --hot --host 0.0.0.0",
|
||||
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"babel-polyfill": "^6.23.0",
|
||||
"crypto-js": "^3.1.9-1",
|
||||
"drag-drop": "^2.13.2",
|
||||
"tus-js-client": "^1.4.3",
|
||||
"uuid": "^3.0.1",
|
||||
"vue": "^2.2.6",
|
||||
"vuex": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.24.1",
|
||||
"babel-loader": "^6.4.1",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-preset-env": "^1.3.2",
|
||||
"babel-preset-stage-2": "^6.22.0",
|
||||
"cross-env": "^4.0.0",
|
||||
"css-loader": "^0.28.0",
|
||||
"file-loader": "^0.11.1",
|
||||
"pug": "^2.0.0-beta.12",
|
||||
"vue-loader": "^11.3.4",
|
||||
"vue-template-compiler": "^2.2.6",
|
||||
"webpack": "^2.4.1",
|
||||
"webpack-dev-server": "^2.4.2"
|
||||
}
|
||||
}
|
||||
140
app/src/Download.vue
Normal file
140
app/src/Download.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template lang="pug">
|
||||
.download-app
|
||||
.btn-group(style='position: absolute; top: 15px; right: 15px;')
|
||||
a.btn.btn-sm.btn-info(@click='newSession()', title='New Upload')
|
||||
i.fa.fa-fw.fa-cloud-upload
|
||||
| new upload
|
||||
.alert.alert-danger(v-show="error")
|
||||
strong
|
||||
i.fa.fa-exclamation-triangle
|
||||
| {{ error }}
|
||||
.well(v-if='needsPassword')
|
||||
h3(style='margin-top: 0') Password
|
||||
.form-group
|
||||
input.form-control(type='password', v-model='password')
|
||||
p.text-danger(v-show='passwordWrong')
|
||||
strong Access denied!
|
||||
|
|
||||
button.btn.btn-primary(:disabled='password.length<1', @click='decrypt()')
|
||||
i.fa.fa-key
|
||||
| decrypt
|
||||
.panel.panel-primary(v-if='!needsPassword')
|
||||
.panel-heading Files
|
||||
.panel-body
|
||||
table.table.table-hover.table-striped(style='margin-bottom: 0')
|
||||
tbody
|
||||
tr(v-for='file in files', style='cursor: pointer', @click='download(file)')
|
||||
td(style='width: 60px')
|
||||
file-icon(:file='file')
|
||||
td
|
||||
p
|
||||
clipboard.pull-right(:value='host + file.url', @change='copied(file, $event)', title='Copy to clipboard', style='margin-left: 5px')
|
||||
a
|
||||
i.fa.fa-fw.fa-copy
|
||||
i.fa.fa-check.text-success.pull-right(v-show='file.downloaded')
|
||||
|
|
||||
strong {{ file.metadata.name }}
|
||||
|
|
||||
small ({{ humanFileSize(file.size) }})
|
||||
p {{ file.metadata.comment }}
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
import AES from 'crypto-js/aes';
|
||||
import encUtf8 from 'crypto-js/enc-utf8';
|
||||
|
||||
import FileIcon from './common/FileIcon.vue';
|
||||
import Clipboard from './common/Clipboard.vue';
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: { FileIcon, Clipboard },
|
||||
data () {
|
||||
return {
|
||||
files: [],
|
||||
sid: document.location.pathname.substr(1),
|
||||
passwordWrong: false,
|
||||
needsPassword: false,
|
||||
password: '',
|
||||
content: '',
|
||||
error: '',
|
||||
host: document.location.protocol + '//' + document.location.host
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
download(file) {
|
||||
if(file.downloaded && file.metadata.retention === 'one-time') {
|
||||
alert('One-Time Download: File is not available anymore.');
|
||||
return;
|
||||
}
|
||||
document.location.href = file.url;
|
||||
file.downloaded = true;
|
||||
},
|
||||
|
||||
copied(file, $event) {
|
||||
file.downloaded = $event === 'copied';
|
||||
},
|
||||
|
||||
decrypt() {
|
||||
this.passwordWrong = false;
|
||||
this.files = this.files.map(item => {
|
||||
if(typeof item === 'object') return item;
|
||||
const d = AES.decrypt(item, this.password);
|
||||
try {
|
||||
return Object.assign(
|
||||
JSON.parse(d.toString(encUtf8)),
|
||||
{downloaded: false}
|
||||
);
|
||||
} catch(e) {
|
||||
this.passwordWrong = true;
|
||||
return item;
|
||||
}
|
||||
});
|
||||
if(!this.passwordWrong) {
|
||||
this.needsPassword = false;
|
||||
this.password = '';
|
||||
}
|
||||
},
|
||||
|
||||
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];
|
||||
},
|
||||
|
||||
newSession() {
|
||||
document.location.href = '/';
|
||||
}
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', '/' + this.sid + '.json');
|
||||
xhr.onload = () => {
|
||||
if(xhr.status === 200) {
|
||||
try {
|
||||
this.files = JSON.parse(xhr.responseText).map(f => {
|
||||
if(typeof f !== 'object') {
|
||||
this.needsPassword = true;
|
||||
return f;
|
||||
}
|
||||
return Object.assign(f, {downloaded: false});
|
||||
});
|
||||
} catch(e) {
|
||||
this.error = e.toString();
|
||||
}
|
||||
} else {
|
||||
this.error = `${xhr.status} ${xhr.statusText}: ${xhr.responseText}`;
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
80
app/src/Upload.vue
Normal file
80
app/src/Upload.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template lang="pug">
|
||||
.upload-app
|
||||
.btn-group(style='position: absolute; top: 15px; right: 15px;')
|
||||
a.btn.btn-sm.btn-info(@click='newSession()', title='New Upload')
|
||||
i.fa.fa-fw.fa-cloud-upload
|
||||
| new upload
|
||||
.alert.alert-danger(v-show="error")
|
||||
strong
|
||||
i.fa.fa-exclamation-triangle
|
||||
| {{ error }}
|
||||
.well(v-show="state === 'uploaded'")
|
||||
.pull-right
|
||||
a.btn.btn-primary(style="margin-right: 5px", :href="mailLnk")
|
||||
i.fa.fa-fw.fa-envelope
|
||||
| Mail
|
||||
clipboard.btn.btn-primary(:value='shareUrl')
|
||||
h3.text-success(style='margin-top: 0')
|
||||
i.fa.fa-check
|
||||
| Upload completed
|
||||
div(style='margin-top: 1rem; padding-bottom: 0')
|
||||
strong(style="margin-right: 5px") Download Link:
|
||||
|
|
||||
a(:href='shareUrl') {{ shareUrl }}
|
||||
.row(style="margin-bottom:10px", v-show="state === 'uploading'")
|
||||
.col-xs-12
|
||||
i.fa.fa-spinner.fa-spin.fa-2x.fa-fw.pull-left
|
||||
.progress(style="height:25px")
|
||||
.progress-bar.progress-bar-success.progress-bar-striped.active(:style="{width: percentUploaded+'%'}", style="line-height:25px")
|
||||
span(v-show='percentUploaded>10') {{ percentUploaded }}%
|
||||
.row
|
||||
.col-sm-7
|
||||
files(:value="[]")
|
||||
.col-sm-5
|
||||
settings
|
||||
.text-right(v-show='files.length && !disabled')
|
||||
button.btn.btn-lg.btn-success(@click="$store.dispatch('upload/upload')")
|
||||
i.fa.fa-upload
|
||||
| upload
|
||||
.text-right(v-show="state === 'uploadError'")
|
||||
button.btn.btn-lg.btn-success(@click="$store.dispatch('upload/upload')")
|
||||
i.fa.fa-upload
|
||||
| retry
|
||||
</template>
|
||||
|
||||
<script type="text/babel">
|
||||
"use strict";
|
||||
import {mapState, mapGetters} from 'vuex';
|
||||
|
||||
import Settings from './Upload/Settings.vue';
|
||||
import Files from './Upload/Files.vue';
|
||||
import Clipboard from './common/Clipboard.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Settings,
|
||||
Files,
|
||||
Clipboard
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['error', 'disabled', 'state']),
|
||||
...mapState('upload', ['sid', 'files']),
|
||||
...mapGetters('upload', ['percentUploaded', 'shareUrl']),
|
||||
mailLnk: function(){
|
||||
return this.$store.state.config
|
||||
&& this.$store.state.config.mailTemplate
|
||||
&& this.$store.state.config.mailTemplate.replace('%%URL%%', this.shareUrl);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
newSession() {
|
||||
if(!confirm('Create a new upload session?')) return;
|
||||
document.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
74
app/src/Upload/Files.vue
Normal file
74
app/src/Upload/Files.vue
Normal 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>
|
||||
63
app/src/Upload/Settings.vue
Normal file
63
app/src/Upload/Settings.vue
Normal 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
41
app/src/Upload/store.js
Normal 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;
|
||||
},
|
||||
},
|
||||
|
||||
});
|
||||
39
app/src/Upload/store/config.js
Normal file
39
app/src/Upload/store/config.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
184
app/src/Upload/store/upload.js
Normal file
184
app/src/Upload/store/upload.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
62
app/src/common/Clipboard.vue
Normal file
62
app/src/common/Clipboard.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<!--
|
||||
# Clipboard Component
|
||||
Copies a string into the clipboard
|
||||
|
||||
## Props
|
||||
* value: String
|
||||
|
||||
## Slots
|
||||
* default: Replaces inner content
|
||||
* text: Replaces the text
|
||||
-->
|
||||
|
||||
<template lang="pug">
|
||||
span(@click.stop='copy()', style='cursor: pointer')
|
||||
slot(:state='state')
|
||||
i.fa.fa-fw(:class="{'fa-copy':state=='pristine','fa-check':state=='copied','fa-exclamation-triangle':state=='error'}")
|
||||
slot(name='text') Copy
|
||||
</template>
|
||||
|
||||
|
||||
<script type="text/babel">
|
||||
"use strict";
|
||||
|
||||
export default {
|
||||
name: "Clipboard",
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
state: 'pristine' // copied, error
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
copy() {
|
||||
let el = document.createElement('textarea');
|
||||
Object.assign(el.style, {
|
||||
position: 'absolute',
|
||||
left: '-200%'
|
||||
});
|
||||
el.value = this.value;
|
||||
document.body.appendChild(el);
|
||||
|
||||
let success = false;
|
||||
try {
|
||||
el.select();
|
||||
success = document.execCommand('copy');
|
||||
}
|
||||
catch(e) {
|
||||
alert('Dein alter Browser unterstützt diese Funktion leider nicht.');
|
||||
console.error(e);
|
||||
}
|
||||
document.body.removeChild(el);
|
||||
this.state = success ? 'copied' : 'error';
|
||||
this.$emit('change', this.state);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
37
app/src/common/FileIcon.vue
Normal file
37
app/src/common/FileIcon.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template lang="pug">
|
||||
div
|
||||
i.fa.fa-fw.fa-3x(v-if='!isImageBlob', :class='iconClass')
|
||||
|
|
||||
img(v-if='isImageBlob', :src='blobUrl', style='width:54px; height:auto;')
|
||||
</template>
|
||||
|
||||
|
||||
<script type="text/babel">
|
||||
"use strict";
|
||||
|
||||
export default {
|
||||
props: ['file'],
|
||||
|
||||
computed: {
|
||||
iconClass() {
|
||||
const type = this.file.type || this.file.metadata && this.file.metadata.type;
|
||||
if(!type) return 'fa-file-o';
|
||||
if(type.startsWith('image')) return 'fa-file-image-o';
|
||||
if(type.startsWith('text')) return 'fa-file-text-o';
|
||||
if(type.startsWith('video')) return 'fa-file-video-o';
|
||||
if(type.startsWith('audio')) return 'fa-file-audio-o';
|
||||
if(type === 'application/pdf') return 'fa-file-pdf-o';
|
||||
if(type.startsWith('application')) return 'fa-file-archive-o';
|
||||
return 'fa-file-o';
|
||||
},
|
||||
isImageBlob() {
|
||||
if(!URL && !webkitURL) return false;
|
||||
return this.file instanceof File && this.file.type.startsWith('image');
|
||||
},
|
||||
blobUrl() {
|
||||
if(!this.isImageBlob) return;
|
||||
return (URL || webkitURL).createObjectURL(this.file);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
10
app/src/download.js
Normal file
10
app/src/download.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'babel-polyfill';
|
||||
import Vue from 'vue';
|
||||
import Download from './Download.vue';
|
||||
|
||||
new Vue({
|
||||
el: '#download',
|
||||
render: h => h(Download)
|
||||
});
|
||||
|
||||
window.PSITRANSFER_VERSION = PSITRANSFER_VERSION;
|
||||
17
app/src/upload.js
Normal file
17
app/src/upload.js
Normal file
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
import 'babel-polyfill';
|
||||
import Vue from 'vue';
|
||||
import Upload from './Upload.vue';
|
||||
import store from './Upload/store.js';
|
||||
|
||||
new Vue({
|
||||
el: '#upload',
|
||||
store,
|
||||
render: h => h(Upload),
|
||||
beforeCreate() {
|
||||
this.$store.dispatch('config/fetch');
|
||||
}
|
||||
});
|
||||
|
||||
window.PSITRANSFER_VERSION = PSITRANSFER_VERSION;
|
||||
97
app/webpack.config.js
Normal file
97
app/webpack.config.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const execSync = require('child_process').execSync;
|
||||
|
||||
let commitShaId;
|
||||
|
||||
try {
|
||||
commitShaId = '#'+execSync('git rev-parse HEAD').toString().substr(0,10);
|
||||
} catch (e) {}
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
upload: './src/upload.js',
|
||||
download: './src/download.js',
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../public/app'),
|
||||
publicPath: '/app/',
|
||||
filename: '[name].js'
|
||||
},
|
||||
plugins: [
|
||||
new webpack.optimize.CommonsChunkPlugin({
|
||||
filename: "common.js",
|
||||
name: "common"
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': {
|
||||
NODE_ENV: process.env.NODE_ENV !== 'development' ? '"production"' : '"development"',
|
||||
},
|
||||
PSITRANSFER_VERSION: '"' + (process.env.PSITRANSFER_VERSION || commitShaId || 'dev') + '"'
|
||||
}),
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
loader: 'vue-loader',
|
||||
options: {
|
||||
loaders: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /node_modules/
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg|gif|svg)$/,
|
||||
loader: 'file-loader',
|
||||
options: {
|
||||
name: '[name].[ext]?[hash]'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'vue$': 'vue/dist/vue.common.js'
|
||||
}
|
||||
},
|
||||
devServer: {
|
||||
historyApiFallback: true,
|
||||
noInfo: true,
|
||||
proxy: [
|
||||
// Proxy requests to BE in DEV mode
|
||||
// https://webpack.github.io/docs/webpack-dev-server.html#proxy
|
||||
{
|
||||
// everything except of js, html, css
|
||||
context: ['/**', '!/**.js', '!/**.html', '!/**.css'],
|
||||
target: 'http://localhost:3000/'
|
||||
}
|
||||
]
|
||||
},
|
||||
performance: {
|
||||
hints: false
|
||||
},
|
||||
devtool: '#eval-source-map'
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
module.exports.devtool = '#source-map';
|
||||
let commit;
|
||||
|
||||
// http://vue-loader.vuejs.org/en/workflow/production.html
|
||||
module.exports.plugins = [
|
||||
...module.exports.plugins,
|
||||
new webpack.optimize.UglifyJsPlugin({
|
||||
sourceMap: true,
|
||||
compress: {
|
||||
warnings: false
|
||||
}
|
||||
}),
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
minimize: true
|
||||
})
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user