diff --git a/package.json b/package.json index 99e042c80..eadf632c4 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,8 @@ "svelte-eslint-parser": "^0.14.0", "svelte-loader": "^3.1.2", "svelte-preprocess": "^4.8.0", - "svelte2tsx": "^0.6.11", - "sveltedoc-parser": "github:ekhaled/sveltedoc-parser#custom", - "typescript": "^5.0.4" + "sveltedoc-parser": "https://github.com/ekhaled/sveltedoc-parser#integration", + "promis": "^1.1.4", + "microevent": "https://github.com/ekhaled/microevent.js.git" } } diff --git a/packages/@kws3/ui/controls/CloudFileUpload.svelte b/packages/@kws3/ui/controls/CloudFileUpload.svelte new file mode 100644 index 000000000..5a4889110 --- /dev/null +++ b/packages/@kws3/ui/controls/CloudFileUpload.svelte @@ -0,0 +1,339 @@ +
+
+ +
+ +
+
+
{drag_msg}
+ +
+
+ +{#if failedValidation.length} + +{/if} + + diff --git a/packages/@kws3/ui/controls/FileUpload.svelte b/packages/@kws3/ui/controls/FileUpload.svelte index a058a6665..f37193d72 100644 --- a/packages/@kws3/ui/controls/FileUpload.svelte +++ b/packages/@kws3/ui/controls/FileUpload.svelte @@ -9,11 +9,19 @@ It is returned back in the `getFile()` call from `file_chosen` event, Default: ` @param {string} [info=""] - Information / help / subtitle displayed under the uploader, Default: `""` @param {ExtendedColorOptions} [info_color=grey] - Color of the information text, Default: `grey` @param {number} [max=5000000] - Maximum allowed size in bytes, Default: `5000000` - @param {any} [allowed=*] - Allowed file types - accepts the string `"*"`, or an array of file type suffixes, Default: `*` + @param {string | array} [allowed=*] - Allowed file types - accepts the string `"*"`, or an array of file type suffixes, Default: `*` @param {boolean} [disabled=false] - Disables the uploader, Default: `false` @param {SizeOptions} [size=] - Size of the File Input, Default: `` @param {ColorOptions} [color=] - Color of the File Input, Default: `` @param {string} [class=""] - CSS classes for the Uploader, Default: `""` + @param {boolean} [multiple=false] - Disables the multiple file uploader, Default: `false` + @param {function} [preparer()] - Cloud Upload preparer + @param {function} [acknowledger()] - Cloud Upload acknowledger + @param {string} [inner_style=""] - CSS style for the Uploader, Default: `""` + @param {boolean} [is_cloud_upload=false] - Specifies Cloud Upload or normal upload, Default: `false` + @param {string} [queue="a-random-queue-name"] - Queue name for cloud Uploader, Default: `"a-random-queue-name"` + @param {string | array} [images] - Images uploaded + @param {string} [notification_position = "bottom-left"] - Notification position speifier, Default: `"bottom-left"` ### Events - `file_uploaded` - Triggered when upload completes @@ -29,64 +37,86 @@ The following functions are returned in `event.detail`: -->
-
-
- {#if _is_uploading} - - {:else if _is_finished} - - {:else} - - {/if} -
-
- {#if _is_uploading} -
-
-
+ on:dragenter={dragenter} + on:dragleave={dragleave} + on:drop={drop} + class="kws-file-upload {klass} is-{color} is-{size} + is-{_error ? 'danger' : ''} {disabled ? 'is-disabled' : ''} + {_is_finished ? 'is-success' : ''} {_is_dragging ? 'is-dragging' : ''}"> +
+ + +
+ {#if _is_uploading} + + {:else if _is_finished} + + {:else} + + {/if} +
+
+ {#if _is_uploading} +
+
+
+
-
-
{_progress}% - Uploading...
- {:else if _is_finished} -
Upload complete!
- {:else} -
{_filename}
- {/if} -
- -
-
- {#if _error} -
- {_error_message} +
{_progress}% - Uploading...
+ {:else if _is_finished} +
Upload complete!
+ {:else} +
{_filename}
+ {/if}
+ + {#if is_cloud_upload} + {:else} -
-
- Max size: {maxFileSize} -
-
-
- {info} -
-
- {fileTypes} -
+ {/if}
diff --git a/packages/@kws3/ui/controls/services/ajax.js b/packages/@kws3/ui/controls/services/ajax.js new file mode 100644 index 000000000..232095d9f --- /dev/null +++ b/packages/@kws3/ui/controls/services/ajax.js @@ -0,0 +1,130 @@ +// Best place to find information on XHR features is: +// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest + +var reqfields = ["responseType", "withCredentials", "timeout", "onprogress"]; + +// Simple and small ajax function +// Takes a parameters object and a callback function +// Parameters: +// - url: string, required +// - headers: object of `{header_name: header_value, ...}` +// - body: +// + string (sets content type to 'application/x-www-form-urlencoded' if not set in headers) +// + FormData (doesn't set content type so that browser will set as appropriate) +// - method: 'GET', 'POST', etc. Defaults to 'GET' or 'POST' based on body +// - cors: If your using cross-origin, you will need this true for IE8-9 +// +// The following parameters are passed onto the xhr object. +// IMPORTANT NOTE: The caller is responsible for compatibility checking. +// - responseType: string, various compatability, see xhr docs for enum options +// - withCredentials: boolean, IE10+, CORS only +// - timeout: long, ms timeout, IE8+ +// - onprogress: callback, IE10+ +// +// Callback function prototype: +// - statusCode from request +// + Possibly null or 0 (i.e. falsy) if an error occurs +// - response +// + if responseType set and supported by browser, this is an object of some type (see docs) +// + otherwise if request completed, this is the string text of the response +// + if request is aborted, this is "Abort" +// + if request times out, this is "Timeout" +// + if request errors before completing (probably a CORS issue), this is "Error" +// - request object +// +// Returns the request object. So you can call .abort() or other methods +// +// DEPRECATIONS: +// - Passing a string instead of the params object has been removed! +// +export default function (params, callback) { + // Any variable used more than once is var'd here because + // minification will munge the variables whereas it can't munge + // the object access. + var headers = params.headers || {}, + body = params.body, + method = params.method || (body ? "POST" : "GET"), + called = false; + + var req = getRequest(params.cors); + + function cb(statusCode, responseText) { + return function () { + if (!called) { + callback( + req.status === undefined ? statusCode : req.status, + req.status === 0 + ? "Error" + : req.response || req.responseText || responseText, + req + ); + called = true; + } + }; + } + + req.open(method, params.url, true); + + var success = (req.onload = cb(200)); + req.onreadystatechange = function () { + if (req.readyState === 4) success(); + }; + req.onerror = cb(null, "Error"); + req.ontimeout = cb(null, "Timeout"); + req.onabort = cb(null, "Abort"); + + if (body) { + setDefault(headers, "X-Requested-With", "XMLHttpRequest"); + + if (window.FormData || window.Blob) { + if ( + !(body instanceof window.FormData) && + !(body instanceof window.Blob) + ) { + setDefault( + headers, + "Content-Type", + "application/x-www-form-urlencoded" + ); + } + } + } + + for (let i = 0, len = reqfields.length, field; i < len; i++) { + field = reqfields[i]; + if (params[field] !== undefined) { + if ( + body && + ((window.FormData && body instanceof window.FormData) || + (window.Blob && body instanceof window.Blob)) && + field === "onprogress" + ) { + req.upload[field] = params[field]; + } else { + req[field] = params[field]; + } + } + } + + for (let field in headers) req.setRequestHeader(field, headers[field]); + + req.send(body); + + return req; +} + +function getRequest(cors) { + // XDomainRequest is only way to do CORS in IE 8 and 9 + // But XDomainRequest isn't standards-compatible + // Notably, it doesn't allow cookies to be sent or set by servers + // IE 10+ is standards-compatible in its XMLHttpRequest + // but IE 10 can still have an XDomainRequest object, so we don't want to use it + if (cors && window.XDomainRequest && !/MSIE 1/.test(navigator.userAgent)) + // eslint-disable-next-line no-undef + return new XDomainRequest(); + if (window.XMLHttpRequest) return new XMLHttpRequest(); +} + +function setDefault(obj, key, value) { + obj[key] = obj[key] || value; +} diff --git a/packages/@kws3/ui/controls/services/net.js b/packages/@kws3/ui/controls/services/net.js new file mode 100644 index 000000000..a82862acd --- /dev/null +++ b/packages/@kws3/ui/controls/services/net.js @@ -0,0 +1,60 @@ +import EventEmitter from "microevent"; +import "promis"; +import Ajax from "./ajax"; + +var network = function () { + var self = this, + key = { access_token: "", refresh_token: "", expires_in: null }; + + EventEmitter.mixin(self); + + function send(params, isRaw, noApiKey) { + return new Promise(function (fulfil, reject) { + if (typeof params.headers == "undefined") { + params.headers = {}; + } + if (!noApiKey) { + params.headers["Api-key"] = key.access_token; + } + + Ajax(params, function (code, response, xhr) { + var resp = {}; + if (code >= 200 && code < 400) { + if (isRaw) { + fulfil(response); + } else { + try { + resp = JSON.parse(response); + } catch (e) { + console.log(e); + } + + if (typeof resp.records != "undefined") { + fulfil({ + code: code, + response: resp.records, + xhr: xhr, + headers: xhr.getAllResponseHeaders(), + _meta: resp._meta, + }); + } else { + //self.trigger("httpError", 501); + reject({ code: code, response: response }); + } + } + } + }); + }); + } + + self.raw = function (params, noApiKey) { + return send(params, true, noApiKey); + }; + + self.ajax = function (params) { + return send(params); + }; +}; + +//this ensures a singleton of this class +export default new network(); diff --git a/packages/@kws3/ui/controls/services/uploadQueueStore.js b/packages/@kws3/ui/controls/services/uploadQueueStore.js new file mode 100644 index 000000000..b9792d41b --- /dev/null +++ b/packages/@kws3/ui/controls/services/uploadQueueStore.js @@ -0,0 +1,138 @@ +import { derived, writable } from "svelte/store"; + +const uploadQueueInit = () => { + const store = writable({}), + unsubList = {}; + + const create = (queue_name, newItem) => { + const __unique_id = + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15); + + store.update((items) => { + if (typeof items[queue_name] === "undefined") { + items[queue_name] = []; + } + items[queue_name].push({ __unique_id, queue_name, state: {} }); + return items; + }); + + const unsub = newItem.subscribe((uploadState) => { + updateItem(queue_name, __unique_id, uploadState); + }); + + unsubList[__unique_id] = unsub; + }; + + const removeItem = (queue_name, __unique_id) => { + store.update((items) => { + if (typeof items[queue_name] !== "undefined") { + items[queue_name] = items[queue_name].filter((item) => { + let match = item.__unique_id !== __unique_id; + if (!match) { + //unsubscribe from readable store + unsubList[item.__unique_id] && unsubList[item.__unique_id](); + } + return match; + }); + } + + return items; + }); + }; + + const clearCompleted = () => { + store.update((items) => { + for (let queue_name in items) { + items[queue_name] = items[queue_name].slice().filter((item) => { + if (item.state.status === "uploaded") { + //unsubscribe from readable store + unsubList[item.__unique_id] && unsubList[item.__unique_id](); + return false; + } + return true; + }); + } + return items; + }); + }; + + const updateItem = (queue_name, __unique_id, uploadState) => { + store.update((items) => { + if (typeof items[queue_name] !== "undefined") { + const _items = items[queue_name]; + _items.slice().forEach((i) => { + if (i.__unique_id === __unique_id) { + i.state = uploadState; + } + }); + + items[queue_name] = _items; + } + return items; + }); + }; + + return { + ...store, + create, + removeItem, + clearCompleted, + }; +}; + +export const uploadQueue = uploadQueueInit(); + +export const activeUploadsCount = derived(uploadQueue, (que) => { + let count = 0; + for (let queue_name in que) { + count += que[queue_name].length; + } + return count; +}); + +export const uploadsList = derived(uploadQueue, (que) => { + let uploads = []; + for (let queue_name in que) { + uploads = uploads.concat(que[queue_name]); + } + return uploads; +}); + +export const totalUploadProgress = derived(uploadsList, (ulist) => { + let total = 0, + active = 0, + complete = 0, + sizeTotal = 0, + failed = 0, + uploadedTotal = 0; + + ulist.forEach((upload) => { + const status = upload.state.status; + total++; + if (status !== "uploaded" && status !== "error") { + active++; + } + if (status === "uploaded") { + complete++; + } + if (status === "failed") { + failed++; + } + sizeTotal += Number(upload.state.total); + uploadedTotal += Number(upload.state.loaded); + }); + + return { + total, + active, + complete, + failed, + remaining: total - complete, + progress: Math.round((uploadedTotal / sizeTotal) * 100), + }; +}); + +export function registerUpload(queue_name, newItem) { + uploadQueue.create(queue_name, newItem); +} diff --git a/packages/@kws3/ui/controls/uploader/UploadNotification.svelte b/packages/@kws3/ui/controls/uploader/UploadNotification.svelte new file mode 100644 index 000000000..e024f4422 --- /dev/null +++ b/packages/@kws3/ui/controls/uploader/UploadNotification.svelte @@ -0,0 +1,101 @@ +
+
+
+

+ {$activeUploadsCount} uploads +

+
+ {#if canClose} +
+ +
+ {/if} +
+
+ {#if $totalUploadProgress.remaining} + {$totalUploadProgress.remaining} remaining {#if $totalUploadProgress.failed}({$totalUploadProgress.failed} failed){/if} + {:else} + All uploads complete + {/if} + + Details + +
+ {#if showList} +
+
+ {#each $uploadsList as item (item.__unique_id)} +
+ + {#if item.state.status === "failed"} +
+ +
+ {/if} +
+ {/each} +
+
+ {:else} + + {/if} +
+ + diff --git a/packages/@kws3/ui/controls/uploader/UploadingItem.svelte b/packages/@kws3/ui/controls/uploader/UploadingItem.svelte new file mode 100644 index 000000000..4b9ec44ba --- /dev/null +++ b/packages/@kws3/ui/controls/uploader/UploadingItem.svelte @@ -0,0 +1,15 @@ +
+ {item.original_name} + +
+ + diff --git a/packages/@kws3/ui/index.js b/packages/@kws3/ui/index.js index 1ef6a8b11..e3ea82338 100644 --- a/packages/@kws3/ui/index.js +++ b/packages/@kws3/ui/index.js @@ -42,6 +42,7 @@ export { default as ProcessButton } from "./buttons/ProcessButton.svelte"; export { default as Checkbox } from "./controls/Checkbox.svelte"; export { default as FileUpload } from "./controls/FileUpload.svelte"; +export { default as CloudFileUpload } from "./controls/CloudFileUpload.svelte"; export { default as NumberInput } from "./controls/NumberInput.svelte"; export { default as Radio } from "./controls/Radio.svelte"; export { default as Toggle } from "./controls/Toggle.svelte"; diff --git a/packages/@kws3/ui/styles/FileUpload.scss b/packages/@kws3/ui/styles/FileUpload.scss index cf17814c2..0a1a26b02 100644 --- a/packages/@kws3/ui/styles/FileUpload.scss +++ b/packages/@kws3/ui/styles/FileUpload.scss @@ -20,10 +20,9 @@ $kws-fileupload-progress-anim-easing: linear !default; .file-upload-inner { position: relative; background: $kws-fileupload-bg-color; - border: 1px solid $kws-fileupload-border-color; border-radius: $kws-fileupload-radius; max-width: 100%; - height: 2.5rem; + min-height: 2.5rem; .file { overflow: hidden; height: 100%; diff --git a/packages/@kws3/ui/upload/index.js b/packages/@kws3/ui/upload/index.js new file mode 100644 index 000000000..bed287d4f --- /dev/null +++ b/packages/@kws3/ui/upload/index.js @@ -0,0 +1,60 @@ +const MIME_TYPES = { + png: { + type: "image/png", + icon: "file-image-o", + }, + jpg: { + type: "image/jpg", + icon: "file-image-o", + }, + jpeg: { + type: "image/jpeg", + icon: "file-image-o", + }, + webp: { + type: "image/webp", + icon: "file-image-o", + }, + pdf: { + type: "application/pdf", + icon: "file-pdf-o", + }, + doc: { + type: "application/msword", + icon: "file-word-o", + }, + docx: { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + icon: "file-word-o", + }, + xls: { + type: "application/vnd.ms-excel", + icon: "file-excel-o", + }, + xlsx: { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + icon: "file-excel-o", + }, + ppt: { + type: "application/vnd.ms-powerpoint", + icon: "file-powerpoint-o", + }, + pptx: { + type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + icon: "file-powerpoint-o", + }, +}; + +/** + * Get MIME. + * @param {array} [allowed_extension=[]] - Array of allowed extensions + */ +export function getMimesFor(allowed_extension = []) { + let accepted_mime = []; + allowed_extension.forEach((extension) => { + if (MIME_TYPES[extension]) { + accepted_mime.push(MIME_TYPES[extension].type); + } + }); + return accepted_mime.join(","); +} diff --git a/src/stories/controls/FileUpload/CloudFileUploadDecorator.svelte b/src/stories/controls/FileUpload/CloudFileUploadDecorator.svelte new file mode 100644 index 000000000..ccf812730 --- /dev/null +++ b/src/stories/controls/FileUpload/CloudFileUploadDecorator.svelte @@ -0,0 +1,44 @@ +
+
+

+ Cloud File Upload +

+ prepareUpload(itemlId, data)} + acknowledger={(data) => ackUpload(itemlId, data)} + max={536870912} + label="Add image or document.." + allowed={allowed_ext} + accept={getMimesFor(allowed_ext)} /> +
+
+ + diff --git a/src/stories/controls/FileUpload/FileUpload.svelte b/src/stories/controls/FileUpload/FileUpload.svelte index 75fdfbe82..1d6a01b4b 100644 --- a/src/stories/controls/FileUpload/FileUpload.svelte +++ b/src/stories/controls/FileUpload/FileUpload.svelte @@ -1,5 +1,6 @@
+

Default design

onFileChosen(event, false)} on:file_uploaded={() => onFileUploaded()} @@ -13,26 +14,162 @@ class={klass} {color} {size} - {info_color} /> -

This will succeed.

+ {info_color} + {multiple} + {accept} + inner_style="min-height: 2.5rem;" + {is_cloud_upload} /> +

+ This will succeed. +

+

+ Custom design-1 +

+ onFileChosen(event, false)} + on:file_uploaded={() => onFileUploaded()} + on:file_upload_error={() => onFileUploadError()} + {allowed} + {disabled} + message="Browse/drop file..." + {key} + {info} + {max} + class={klass} + {color} + {size} + {info_color} + {multiple} + {accept} + inner_style="min-height: 2.5rem;" + {is_cloud_upload}> +
+
+ {#if uploading} +
+

{progress}%

+
+ {:else if finished} +
+ Finished +
+ {:else} +
+ {filename} + +
+ {/if} +
+
+
+

+ This will succeed. +

+
+ +
+

+ Custom design-2 +

onFileChosen(event, true)} on:file_uploaded={() => onFileUploaded()} on:file_upload_error={() => onFileUploadError()} {allowed} {disabled} - {message} + message="Browse/Drop file(s)" {key} {info} {max} class={klass} {color} {size} - {info_color} /> -

This will fail.

+ {info_color} + {multiple} + {accept} + inner_style="min-height:8rem" + {is_cloud_upload}> +
+
+
+
+ + {#if uploading} + + {:else if finished} + + {:else} + + {/if} + + {#if uploading} + + {progress ? progress + "% - " : ""} Uploading... + + {:else if finished} + Upload complete! + {:else} +
+ {filename} +
+ {/if} +
+
+ +
+ {#if error} +
+ {error_message} +
+ {:else} +
+ + {!multiple ? "Max size:" + maxFileSize : ""} + +
+ {/if} +
+ + {#if !error} +
+ {fileTypes} +
+ {/if} + + {#if info} +
+ {info} +
+ {/if} +
+
+
+
+ +

+ This will fail. +

@@ -48,9 +185,63 @@
{/if} +{#if images && images.length} +
+ {#each images as image} +
+
+
+ +
+
+
+ {/each} +
+{/if} + + +