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 { - + ); }