diff --git a/client/assets/img/refresh.svg b/client/assets/img/refresh.svg
new file mode 100644
index 00000000..6b6129c7
--- /dev/null
+++ b/client/assets/img/refresh.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/assets/img/stop.svg b/client/assets/img/stop.svg
new file mode 100644
index 00000000..f6976ad7
--- /dev/null
+++ b/client/assets/img/stop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/components/icon.js b/client/components/icon.js
index b8cce2f7..ea969aab 100644
--- a/client/components/icon.js
+++ b/client/components/icon.js
@@ -39,6 +39,8 @@ import img_info from '../assets/img/info.svg';
import img_fullscreen from '../assets/img/fullscreen.svg';
import img_camera from '../assets/img/camera.svg';
import img_location from '../assets/img/location.svg';
+import img_stop from '../assets/img/stop.svg';
+import img_refresh from '../assets/img/refresh.svg';
export const img_placeholder = "/assets/icons/placeholder.png";
export const Icon = (props) => {
@@ -126,6 +128,10 @@ export const Icon = (props) => {
img = img_camera;
}else if(props.name === 'location'){
img = img_location;
+ } else if (props.name === 'stop') {
+ img = img_stop;
+ } else if (props.name === 'refresh') {
+ img = img_refresh;
}else{
throw('unknown icon: "'+props.name+"'");
}
diff --git a/client/components/index.js b/client/components/index.js
index 3892b2ac..329a0c90 100644
--- a/client/components/index.js
+++ b/client/components/index.js
@@ -21,3 +21,4 @@ export { Dropdown, DropdownButton, DropdownList, DropdownItem } from './dropdown
export { MapShot } from './mapshot';
export { LoggedInOnly, ErrorPage, LoadingPage } from './decorator';
export { FormBuilder } from './formbuilder';
+export { UploadQueue } from './upload_queue';
diff --git a/client/components/upload_queue.js b/client/components/upload_queue.js
new file mode 100644
index 00000000..1d3148e0
--- /dev/null
+++ b/client/components/upload_queue.js
@@ -0,0 +1,381 @@
+import React from 'react';
+import Path from 'path';
+
+import { Files } from '../model/';
+import { confirm, notify, upload } from '../helpers/';
+import { Icon, NgIf } from './';
+import './upload_queue.scss';
+
+const MAX_POOL_SIZE = 15;
+
+function humanFileSize(bytes, si) {
+ var thresh = si ? 1000 : 1024;
+ if (Math.abs(bytes) < thresh) {
+ return bytes.toFixed(1) + ' B';
+ }
+ var units = si
+ ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+ : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+ var u = -1;
+ do {
+ bytes /= thresh;
+ ++u;
+ } while (Math.abs(bytes) >= thresh && u < units.length - 1);
+ return bytes.toFixed(1) + ' ' + units[u];
+}
+
+export class UploadQueue extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ timeout: 1,
+ running: false,
+ files: [],
+ processes: [],
+ currents: [],
+ failed: [],
+ finished: [],
+ prior_status: {},
+ progress: {},
+ speed: [],
+ };
+ }
+
+ componentDidMount() {
+ if (typeof this.state.timeout === "number") {
+ this.setState({
+ timeout: window.setTimeout(() => {
+ this.componentDidMount();
+ }, Math.random() * 1000 + 200)
+ });
+ }
+ upload.subscribe((path, files) => this.addFiles(path, files));
+ }
+
+ componentWillUnmount() {
+ window.clearTimeout(this.state.timeout);
+ }
+
+ reset() {
+ this.setState({
+ files: [],
+ processes: [],
+ currents: [],
+ failed: [],
+ finished: [],
+ prior_status: {},
+ progress: {},
+ speed: [],
+ });
+ }
+
+ emphasis(path) {
+ notify.send(path.split("/").join(" / "), "info");
+ }
+
+ runner(id) {
+ let current_process = null;
+ let processes = [...this.state.processes];
+ if (processes.length === 0 || !this.state.running) {
+ return Promise.resolve();
+ }
+
+ var i;
+ for (i = 0; i < processes.length; i++) {
+ if (
+ // init: getting started with creation of files/folders
+ processes[i].parent === null ||
+ // running: make sure we've created the parent folder
+ this.state.prior_status[processes[i].parent] === true
+ ) {
+ current_process = this.state.processes[i];
+ processes.splice(i, 1);
+ this.setState({
+ processes,
+ currents: [...this.state.currents, current_process],
+ })
+ break;
+ }
+ }
+
+ if (current_process) {
+ return current_process.fn(id)
+ .then(() => {
+ if (current_process.id) {
+ this.setState({
+ prior_status: {
+ ...this.state.prior_status,
+ [current_process.id]: true
+ }
+ })
+ }
+ this.setState({
+ currents: this.state.currents.filter((c) => c.path != current_process.path),
+ finished: [...this.state.finished, current_process],
+ })
+ return this.runner(id);
+ })
+ .catch((err) => {
+ current_process.err = err;
+ this.setState({
+ failed: [...this.state.failed, current_process],
+ currents: this.state.currents.filter((c) => c.path != current_process.path),
+ });
+ let { message } = err;
+ if (message !== 'aborted') {
+ notify.send(err, 'error');
+ }
+ return this.runner(id);
+ });
+ } else {
+ function waitABit() {
+ return new Promise((done) => {
+ window.setTimeout(() => {
+ requestAnimationFrame(() => {
+ done();
+ });
+ }, 250);
+ });
+ }
+ return waitABit().then(() => this.runner(id));
+ }
+ }
+
+ updateProgress(path, e) {
+ if (e.lengthComputable) {
+ let prev = this.state.progress[path];
+ this.setState({
+ progress: {
+ ...this.state.progress,
+ [path]: {
+ ...prev,
+ percent: Math.round(100 * e.loaded / e.total),
+ loaded: e.loaded,
+ time: Date.now(),
+ prev: prev ? prev : null,
+ }
+ }
+ });
+ }
+ }
+
+ updateAbort(path, abort) {
+ this.setState({
+ progress: {
+ ...this.state.progress,
+ [path]: {
+ ...this.state.progress[path],
+ abort,
+ },
+ }
+ })
+ }
+
+ addFiles(path, files) {
+ const processes = files.map((file) => {
+ let original_path = file.path;
+ file.path = Path.join(path, file.path);
+ if (file.type === 'file') {
+ if (files.length < 150) Files.touch(file.path, file.file, 'prepare_only');
+ return {
+ path: original_path,
+ parent: file._prior || null,
+ fn: Files.touch.bind(
+ Files, file.path, file.file, 'execute_only',
+ {
+ progress: (e) => this.updateProgress(original_path, e),
+ abort: (x) => this.updateAbort(original_path, x),
+ }
+ )
+ };
+ } else {
+ Files.mkdir(file.path, 'prepare_only');
+ return {
+ id: file._id || null,
+ path: original_path,
+ parent: file._prior || null,
+ fn: Files.mkdir.bind(Files, file.path, 'execute_only')
+ };
+ }
+ });
+
+ this.setState({
+ processes: [...this.state.processes, ...processes],
+ files: [...this.state.files, ...files],
+ });
+ this.start();
+ }
+
+ retryFiles(process) {
+ this.setState({
+ processes: [...this.state.processes, process],
+ failed: this.state.failed.filter((c) => c.path != process.path),
+ });
+ window.setTimeout(() => this.start(), 300);
+ }
+
+ start() {
+ if (!this.state.running) {
+ window.setTimeout(() => this.calcSpeed(), 500);
+ this.setState({
+ running: true,
+ });
+
+ Promise.all(Array.apply(null, Array(MAX_POOL_SIZE)).map((process, index) => {
+ return this.runner();
+ })).then(() => {
+ window.setTimeout(() => {
+ notify.send('Upload completed', 'success');
+ }, 300);
+ this.setState({ running: false });
+ }).catch((err) => {
+ notify.send(err, 'error');
+ this.setState({ running: false });
+ });
+ }
+ }
+
+ abort(p) {
+ let info = this.state.progress[p.path];
+ if (info && info.abort) {
+ info.abort();
+ }
+ }
+
+ getCurrentPercent(path) {
+ let info = this.state.progress[path];
+ if (info && info.percent) {
+ return this.state.progress[path].percent + "%";
+ }
+ return "0%"
+ }
+
+ calcSpeed() {
+ let now = Date.now();
+ let curSpeed = [];
+ for (const [key, value] of Object.entries(this.state.progress)) {
+ if (value.prev && now - value.time < 5 * 1000) {
+ let bytes = value.loaded - value.prev.loaded;
+ let timeMs = value.time - value.prev.time;
+ curSpeed.push(1000 * bytes / timeMs);
+ }
+ }
+ let avgSpeed = curSpeed.reduce(function (p, c, i) { return p + (c - p) / (i + 1) }, 0);
+ this.setState({
+ speed: [...this.state.speed, avgSpeed].slice(-5),
+ });
+ if (this.state.running) {
+ window.setTimeout(() => this.calcSpeed(), 500);
+ }
+ }
+
+ getState() {
+ let avgSpeed = this.state.speed.reduce(function (p, c, i) { return p + (c - p) / (i + 1) }, 0);
+ let speedStr = ""
+ if (avgSpeed > 0) {
+ speedStr = " ~ " + humanFileSize(avgSpeed) + "/s";
+ }
+ if (this.state.running) {
+ return "Running..." + speedStr
+ }
+ return "Done" + speedStr
+ }
+
+ onClose() {
+ if(this.state.running) {
+ confirm.now(
+ "Abort current uploads?",
+ () => {
+ this.setState({
+ running: false,
+ });
+ this.state.currents.map(p => this.abort(p));
+ window.setTimeout(() => this.reset(), 30);
+ },
+ () => {}
+ );
+ } else {
+ this.reset()
+ }
+ }
+
+ renderRows(arr, state, col_state, action) {
+ let row_class = state + "_color";
+ return arr.slice(0, 1000).map((process, i) => {
+ return (
+
+
this.emphasis(process.path)}
+ >
+ {process.path.replace(/\//, '')}
+
+ {col_state(process)}
+
+ {action ? action(process): ()}
+
+
+ );
+ });
+ }
+
+ render() {
+ let { finished, files, processes, currents, failed } = this.state;
+ let totalFiles = files.length;
+ return (
+ 0}>
+
+
+ CURRENT UPLOAD
+
+ {finished.length}
+ {totalFiles}
+
+ this.onClose()} />
+
+
{this.getState()}
+
+ {this.renderRows(
+ finished,
+ "done",
+ (_) => (
Done
),
+ )}
+ {this.renderRows(
+ currents,
+ "current",
+ (p) => (
+
+ {this.getCurrentPercent(p.path)}
+
+ ),
+ (p) => (
+
this.abort(p)} >
+ )
+ )}
+ {this.renderRows(
+ processes,
+ "todo",
+ (_) => (
+
Waiting
+ )
+ )}
+ {this.renderRows(
+ failed,
+ "error",
+ (p) => (
+ (p.err && p.err.message == 'aborted')
+ ?
+
Aborted
+ :
+
Error
+ ),
+ (p) => (
+
this.retryFiles(p)} >
+ )
+ )}
+
+
+
+ );
+ }
+}
diff --git a/client/components/upload_queue.scss b/client/components/upload_queue.scss
new file mode 100644
index 00000000..678198ac
--- /dev/null
+++ b/client/components/upload_queue.scss
@@ -0,0 +1,99 @@
+.component_stats{
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 999;
+ max-width: 300px;
+
+ box-shadow: 1px 2px 20px rgba(0, 0, 0, 0.1);
+ background: white;
+ padding: 20px;
+
+ h2 {
+ margin: 0 0 5px 0;
+ font-size: 1.2em;
+ font-weight: 100;
+ .percent{color: var(--emphasis-primary);}
+ .count_block {
+ display: inline;
+ margin-left: 10px;
+ span.grandTotal{
+ font-size: 0.8em;
+ color: var(--emphasis-secondary);
+ &:before { content: "/"; }
+ }
+ span.completed{
+ color: var(--emphasis-secondary);
+ }
+ }
+ .component_icon {
+ cursor: pointer;
+ margin-left: 10px;
+ width: 32px;
+ float: right;
+ }
+ }
+ h3 {
+ margin: 0 0 5px 0;
+ font-size: 1.0em;
+ font-weight: 100;
+ }
+ .stats_content {
+ clear: both;
+ max-height: 200px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ font-size: 0.85em;
+
+ .error_color{
+ color: var(--error);
+ }
+ .todo_color{
+ color: var(--light);
+ }
+
+ .file_row {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ width: 100%;
+
+ .file_path {
+ padding: 5px;
+ display: flex;
+ flex-direction: column;
+ flex-basis: 100%;
+ flex: 1;
+
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .file_state {
+ padding: 5px;
+ display: flex;
+ flex-direction: column;
+ width: 40px;
+ }
+
+ &:hover {
+ .file_control img {
+ display: block !important;
+ cursor: pointer;
+ }
+ }
+
+ .file_control {
+ padding: 5px;
+ display: flex;
+ flex-direction: column;
+ width: 18px;
+ height: 18px;
+
+ img {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/client/helpers/ajax.js b/client/helpers/ajax.js
index 1f2c1004..7b555f3c 100644
--- a/client/helpers/ajax.js
+++ b/client/helpers/ajax.js
@@ -33,7 +33,7 @@ export function http_get(url, type = 'json'){
});
}
-export function http_post(url, data, type = 'json'){
+export function http_post(url, data, type = 'json', params){
return new Promise((done, err) => {
var xhr = new XMLHttpRequest();
xhr.open("POST", url, true);
@@ -43,6 +43,9 @@ export function http_post(url, data, type = 'json'){
data = JSON.stringify(data);
xhr.setRequestHeader('Content-Type', 'application/json');
}
+ if (params !== undefined && params.progress) {
+ xhr.upload.addEventListener("progress", params.progress, false);
+ }
xhr.send(data);
xhr.onload = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
@@ -65,6 +68,12 @@ export function http_post(url, data, type = 'json'){
xhr.onerror = function(){
handle_error_response(xhr, err)
}
+ if (params !== undefined && params.abort) {
+ params.abort(() => {
+ xhr.abort();
+ err({ message: 'aborted' });
+ })
+ }
});
}
diff --git a/client/helpers/index.js b/client/helpers/index.js
index 6995f41c..d77a495e 100644
--- a/client/helpers/index.js
+++ b/client/helpers/index.js
@@ -14,3 +14,4 @@ export { leftPad, format, copyToClipboard } from './common';
export { getMimeType } from './mimetype';
export { settings_get, settings_put } from './settings';
export { FormObjToJSON, createFormBackend, autocomplete } from './form';
+export { upload } from './upload';
diff --git a/client/helpers/upload.js b/client/helpers/upload.js
new file mode 100644
index 00000000..da5b4582
--- /dev/null
+++ b/client/helpers/upload.js
@@ -0,0 +1,16 @@
+const Upload = function () {
+ let fn = null;
+
+ return {
+ add: function (path, files) {
+ if (!fn) { return window.setTimeout(() => this.add(path, files), 50); }
+ fn(path, files);
+ return Promise.resolve();
+ },
+ subscribe: function (_fn) {
+ fn = _fn;
+ }
+ };
+};
+
+export const upload = new Upload();
diff --git a/client/model/files.js b/client/model/files.js
index 32156f98..faf16192 100644
--- a/client/model/files.js
+++ b/client/model/files.js
@@ -261,7 +261,7 @@ class FileSystem{
}
}
- touch(path, file, step){
+ touch(path, file, step, params){
const origin_path = pathBuilder(this.current_path, basename(path), 'file'),
destination_path = path;
@@ -308,7 +308,7 @@ class FileSystem{
const url = appendShareToUrl('/api/files/cat?path='+prepare(path));
let formData = new window.FormData();
formData.append('file', file);
- return http_post(url, formData, 'multipart');
+ return http_post(url, formData, 'multipart', params);
}else{
const url = appendShareToUrl('/api/files/touch?path='+prepare(path));
return http_get(url);
diff --git a/client/pages/filespage.helper.js b/client/pages/filespage.helper.js
index 329cfd81..5e38e01a 100644
--- a/client/pages/filespage.helper.js
+++ b/client/pages/filespage.helper.js
@@ -1,7 +1,7 @@
import React from 'react';
import { Files } from '../model/';
-import { notify, alert, currentShare } from '../helpers/';
+import { notify, upload } from '../helpers/';
import Path from 'path';
import { Observable } from "rxjs/Observable";
@@ -120,9 +120,6 @@ export const onMultiRename = function(arrOfPath){
* 3. user is coming from a upload form button as he doesn't have drag and drop with files
*/
export const onUpload = function(path, e){
- const MAX_POOL_SIZE = 15;
- let PRIOR_STATUS = {};
-
let extractFiles = null;
if(e.dataTransfer === undefined){ // case 3
extractFiles = extract_upload_crappy_hack_but_official_way(e.target);
@@ -141,165 +138,7 @@ export const onUpload = function(path, e){
})
}
- extractFiles.then((files) => {
- var failed = [],
- currents = [];
-
- const processes = files.map((file) => {
- let original_path = file.path;
- file.path = Path.join(path, file.path);
- if(file.type === 'file'){
- if(files.length < 150) Files.touch(file.path, file.file, 'prepare_only');
- return {
- path: original_path,
- parent: file._prior || null,
- fn: Files.touch.bind(Files, file.path, file.file, 'execute_only')
- };
- }else{
- Files.mkdir(file.path, 'prepare_only');
- return {
- id: file._id || null,
- path: original_path,
- parent: file._prior || null,
- fn: Files.mkdir.bind(Files, file.path, 'execute_only')
- };
- }
- });
- class Stats extends React.Component {
- constructor(props){
- super(props);
- this.state = {timeout: 1};
- }
-
- componentDidMount(){
- if(typeof this.state.timeout === "number"){
- this.setState({
- timeout: window.setTimeout(() => {
- this.componentDidMount();
- }, Math.random()*1000+200)
- });
- }
- }
-
- componentWillUnmount(){
- window.clearTimeout(this.state.timeout);
- }
-
- emphasis(path){
- notify.send(path.split("/").join(" / "), "info");
- }
-
- render() {
- const percent = Math.floor(100 * (files.length - processes.length - currents.length) / files.length);
- return (
-
-
- UPLOADING ({percent}%)
-
- {files.length - processes.length - currents.length}
- {files.length}
-
-
-
- {
- currents.slice(0, 1000).map((process, i) => {
- return (
-
this.emphasis(process.path)} className="current_color" key={i}>{process.path.replace(/\//, '')}
- );
- })
- }
- {
- processes.slice(0, 1000).map((process, i) => {
- return (
-
this.emphasis(process.path)} className="todo_color" key={i}>{process.path.replace(/\//, '')}
- );
- })
- }
- {
- failed.slice(0, 500).map((process, i) => {
- return (
-
this.emphasis(process.path)} className="error_color" key={i}>{process.path}
- );
- })
- }
-
-
- );
- }
- }
-
- function runner(id){
- let current_process = null;
- if(processes.length === 0) return Promise.resolve();
-
- var i;
- for(i=0; i {
- if(current_process.id) PRIOR_STATUS[current_process.id] = true;
- currents = currents.filter((c) => c.path != current_process.path);
- return runner(id);
- })
- .catch((err) => {
- failed.push(current_process);
- currents = currents.filter((c) => c.path != current_process.path);
- notify.send(err, 'error');
- return runner(id);
- });
- }else{
- return waitABit()
- .then(() => runner(id));
-
- function waitABit(){
- return new Promise((done) => {
- window.setTimeout(() => {
- requestAnimationFrame(() => {
- done();
- });
- }, 250);
- });
- }
- }
- }
-
- if(files.length >= 5){
- alert.now(, () => {});
- }
- Promise.all(Array.apply(null, Array(MAX_POOL_SIZE)).map((process,index) => {
- return runner();
- })).then(() => {
- // remove the popup
- if(failed.length === 0){
- var e = new Event("keydown");
- e.keyCode = 27;
- window.dispatchEvent(e);
- }
- currents = [];
- // display message
- window.setTimeout(() => {
- notify.send('Upload completed', 'success');
- }, 300);
- }).catch((err) => {
- currents = [];
- notify.send(err, 'error');
- });
- });
-
-
+ extractFiles.then((files) => upload.add(path, files));
// adapted from: https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
function _rand_id(){
diff --git a/client/pages/filespage.scss b/client/pages/filespage.scss
index 0ebda734..061c888d 100644
--- a/client/pages/filespage.scss
+++ b/client/pages/filespage.scss
@@ -42,43 +42,3 @@
height: 100%;
}
}
-
-.component_stats{
- h2{
- margin: 0 0 5px 0;
- font-size: 1.2em;
- font-weight: 100;
- .percent{color: var(--emphasis-primary);}
- > div{
- float: right;
- span.grandTotal{
- font-size: 0.8em;
- color: var(--emphasis-secondary);
- &:before { content: "/"; }
- }
- span.completed{
- color: var(--emphasis-secondary);
- }
- }
- }
- .stats_content {
- clear: both;
- max-height: 150px;
- overflow-y: auto;
- overflow-x: hidden;
- font-size: 0.85em;
- div{
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- width: calc(100% - 10px);
- -webkit-overflow-scrolling: touch;
- }
- .error_color{
- color: var(--error);
- }
- .todo_color{
- color: var(--light);
- }
- }
-}
diff --git a/client/router.js b/client/router.js
index 5188aee3..1ce63168 100644
--- a/client/router.js
+++ b/client/router.js
@@ -2,7 +2,7 @@ import React from 'react';
import { BrowserRouter, Route, IndexRoute, Switch } from 'react-router-dom';
import { NotFoundPage, ConnectPage, HomePage, SharePage, LogoutPage, FilesPage, ViewerPage } from './pages/';
import { URL_HOME, URL_FILES, URL_VIEWER, URL_LOGIN, URL_LOGOUT } from './helpers/';
-import { Bundle, ModalPrompt, ModalAlert, ModalConfirm, Notification, Audio, Video } from './components/';
+import { Bundle, ModalPrompt, ModalAlert, ModalConfirm, Notification, Audio, Video, UploadQueue } from './components/';
const AdminPage = (props) => (
@@ -27,7 +27,7 @@ export default class AppRouter extends React.Component {
-
+
);
}