diff --git a/packages/file-handling/src/fileupload.js b/packages/file-handling/src/fileupload.js
new file mode 100644
index 0000000000000000000000000000000000000000..78a05770e1ed1fb0759895ec39c04b676de85628
--- /dev/null
+++ b/packages/file-handling/src/fileupload.js
@@ -0,0 +1,489 @@
+import {i18n} from './i18n';
+import {css, html} from 'lit-element';
+import {ScopedElementsMixin} from '@open-wc/scoped-elements';
+// import JSONLD from 'vpu-common/jsonld';
+import VPULitElement from 'vpu-common/vpu-lit-element';
+import * as commonUtils from "vpu-common/utils";
+import {Icon, MiniSpinner} from 'vpu-common';
+import * as commonStyles from 'vpu-common/styles';
+import {NextcloudFilePicker} from "./vpu-nextcloud-file-picker";
+import {classMap} from 'lit-html/directives/class-map.js';
+
+
+function mimeTypesToAccept(mimeTypes) {
+    // Some operating systems can't handle mime types and
+    // need file extensions, this tries to add them for some..
+    let mapping = {
+        'application/pdf': ['.pdf'],
+        'application/zip': ['.zip'],
+    };
+    let accept = [];
+    mimeTypes.split(',').forEach((mime) => {
+        accept.push(mime);
+        if (mime.trim() in mapping) {
+            accept = accept.concat(mapping[mime.trim()]);
+        }
+    });
+    return accept.join(',');
+}
+
+
+/**
+ * KnowledgeBaseWebPageElementView web component
+ */
+export class FileUpload extends ScopedElementsMixin(VPULitElement) {
+    constructor() {
+        super();
+        this.lang = 'de';
+        this.url = '';
+        this.nextcloudAuthUrl = '';
+        this.nextcloudWebDavUrl = '';
+        this.dropArea = null;
+        this.allowedMimeTypes = '*/*';
+        this.text = '';
+        this.buttonLabel = '';
+        this.uploadInProgress = false;
+        this.multipleUploadInProgress = false;
+        this.alwaysSendFile = false;
+        this.isDeferred = false;
+        this.queuedFiles = [];
+        this.queuedFilesCount = 0;
+        this.disabled = false;
+        this.decompressZip = false;
+        this._queueKey = 0;
+    }
+
+    static get scopedElements() {
+        return {
+            'vpu-icon': Icon,
+            'vpu-mini-spinner': MiniSpinner,
+            'vpu-nextcloud-file-picker': NextcloudFilePicker,
+        };
+    }
+
+    /**
+     * See: https://lit-element.polymer-project.org/guide/properties#initialize
+     */
+    static get properties() {
+        return {
+            lang: { type: String },
+            url: { type: String },
+            allowedMimeTypes: { type: String, attribute: 'allowed-mime-types' },
+            nextcloudAuthUrl: { type: String, attribute: 'nextcloud-auth-url' },
+            nextcloudWebDavUrl: { type: String, attribute: 'nextcloud-web-dav-url' },
+            text: { type: String },
+            buttonLabel: { type: String, attribute: 'button-label' },
+            uploadInProgress: { type: Boolean, attribute: false },
+            multipleUploadInProgress: { type: Boolean, attribute: false },
+            alwaysSendFile: { type: Boolean, attribute: 'always-send-file' },
+            isDeferred: { type: Boolean, attribute: 'deferred' },
+            queuedFilesCount: { type: Number, attribute: false },
+            disabled: { type: Boolean },
+            decompressZip: { type: Boolean, attribute: 'decompress-zip' },
+        };
+    }
+
+
+    update(changedProperties) {
+        changedProperties.forEach((oldValue, propName) => {
+            switch (propName) {
+                case "lang":
+                    i18n.changeLanguage(this.lang);
+                    break;
+                case "queuedFilesCount":
+                    const data = { "queuedFilesCount": this.queuedFilesCount, "queuedFiles": this.queuedFiles };
+                    const event = new CustomEvent("vpu-fileupload-queued-files-changed",
+                        { "detail": data, bubbles: true, composed: true });
+                    this.dispatchEvent(event);
+                    break;
+            }
+        });
+
+        super.update(changedProperties);
+    }
+
+    connectedCallback() {
+        super.connectedCallback();
+
+        this.updateComplete.then(() => {
+            this.dropArea = this._('#dropArea');
+            ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
+                this.dropArea.addEventListener(eventName, this.preventDefaults, false)
+            });
+            ['dragenter', 'dragover'].forEach(eventName => {
+                this.dropArea.addEventListener(eventName, this.highlight.bind(this), false)
+            });
+            ['dragleave', 'drop'].forEach(eventName => {
+                this.dropArea.addEventListener(eventName, this.unhighlight.bind(this), false)
+            });
+            this.dropArea.addEventListener('drop', this.handleDrop.bind(this), false);
+            this._('#fileElem').addEventListener('change', this.handleChange.bind(this));
+        });
+    }
+
+    preventDefaults (e) {
+        e.preventDefault();
+        e.stopPropagation();
+    }
+
+    highlight(e) {
+        if (this.uploadInProgress) {
+            return;
+        }
+
+        this.dropArea.classList.add('highlight')
+    }
+
+    unhighlight(e) {
+        this.dropArea.classList.remove('highlight')
+    }
+
+    handleDrop(e) {
+        if (this.uploadInProgress || this.disabled) {
+            return;
+        }
+
+        let dt = e.dataTransfer;
+        console.dir(dt);
+        let files = dt.files;
+
+        this.handleFiles(files);
+    }
+
+    async handleChange(e) {
+        let fileElem = this._('#fileElem');
+
+        if (fileElem.files.length === 0) {
+            return;
+        }
+
+        await this.handleFiles(fileElem.files);
+
+        // reset the element's value so the user can upload the same file(s) again
+        fileElem.value = '';
+    }
+
+    /**
+     * Handles files that were dropped to or selected in the component
+     *
+     * @param files
+     * @returns {Promise<void>}
+     */
+    async handleFiles(files) {
+        console.log('handleFiles: files.length = ' + files.length);
+        this.multipleUploadInProgress = true;
+
+        this.dispatchEvent(new CustomEvent("vpu-fileupload-all-start",
+            { "detail": {}, bubbles: true, composed: true }));
+
+        // we need to copy the files to another array or else they will be gone in the setTimeout function!
+        let tempFilesToHandle = [];
+        await commonUtils.asyncArrayForEach(files, async (file) => {
+            if (file.size === 0) {
+                console.log('file \'' + file.name + '\' has size=0 and is denied!')
+                return;
+            }
+
+            // check if we want to decompress the zip and queue the contained files
+            if (this.decompressZip && file.type === "application/zip") {
+                // add decompressed files to tempFilesToHandle
+                tempFilesToHandle = tempFilesToHandle.concat(await this.decompressZIP(file));
+
+                return;
+            } else if (this.allowedMimeTypes && !this.checkFileType(file)) {
+                return;
+            }
+
+            tempFilesToHandle.push(file);
+        });
+
+        // the browsers don't render updates to the dom while these files are handled!
+        // if we set a small delay the dom changes will be rendered
+        setTimeout(async () => {
+            // we need to wait for each upload until we start the next one
+            await commonUtils.asyncArrayForEach(tempFilesToHandle, async (file) =>
+                this.isDeferred ? this.queueFile(file) : this.uploadFile(file));
+
+            this.multipleUploadInProgress = false;
+
+            this.dispatchEvent(new CustomEvent("vpu-fileupload-all-finished",
+                { "detail": {}, bubbles: true, composed: true }));
+        }, 100);
+    }
+
+    checkFileType(file) {
+        // check if file is allowed
+        const [fileMainType, fileSubType] = file.type.split('/');
+        const mimeTypes = this.allowedMimeTypes.split(',');
+        let deny = true;
+
+        mimeTypes.forEach((str) => {
+            const [mainType, subType] = str.split('/');
+            deny = deny && ((mainType !== '*' && mainType !== fileMainType) || (subType !== '*' && subType !== fileSubType));
+        });
+
+        if (deny) {
+            console.log(`mime type ${file.type} of file '${file.name}' is not compatible with ${this.allowedMimeTypes}`);
+
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Decompress files synchronously
+     *
+     * @param file
+     * @returns {Promise<[]>}
+     */
+    async decompressZIP(file) {
+        // see: https://stuk.github.io/jszip/
+        let JSZip = (await import('jszip/dist/jszip.js')).default;
+        let filesToHandle = [];
+
+        // load zip file
+        await JSZip.loadAsync(file)
+            .then(async (zip) => {
+                // we are not using zip.forEach because we need to handle those files synchronously which
+                // isn't supported by JSZip (see https://github.com/Stuk/jszip/issues/281)
+                // using zip.files directly works great!
+                await commonUtils.asyncObjectForEach(zip.files, async (zipEntry) => {
+                    // skip directory entries
+                    if (zipEntry.dir) {
+                        return;
+                    }
+
+                    await zipEntry.async("blob")
+                        .then(async (blob) => {
+                            // get mime type of Blob, see https://github.com/Stuk/jszip/issues/626
+                            const mimeType = await commonUtils.getMimeTypeOfFile(blob);
+
+                            // create new file with name and mime type
+                            const zipEntryFile = new File([blob], zipEntry.name, { type: mimeType });
+
+                            // check mime type
+                            if (!this.checkFileType(zipEntryFile)) {
+                                return;
+                            }
+
+                            filesToHandle.push(zipEntryFile);
+                        }, (e) => {
+                            // handle the error
+                            console.error("Decompressing of file in " + file.name + " failed: " + e.message);
+                        });
+                    });
+            }, function (e) {
+                // handle the error
+                console.error("Loading of " + file.name + " failed: " + e.message);
+            });
+
+        return filesToHandle;
+    }
+
+    async sendFinishedEvent(response, file, sendFile = false) {
+        if (response === undefined) {
+            return;
+        }
+
+        let data =  {
+            fileName: file.name,
+            status: response.status,
+            json: {"hydra:description": ""}
+        };
+
+        try {
+            await response.json().then((json) => {
+                data.json = json;
+            });
+        } catch (e) {}
+
+        if (sendFile) {
+            data.file = file;
+        }
+
+        const event = new CustomEvent("vpu-fileupload-file-finished", { "detail": data, bubbles: true, composed: true });
+        this.dispatchEvent(event);
+    }
+
+    sendStartEvent(file) {
+        let data =  {
+            fileName: file.name,
+            fileSize: file.size,
+        };
+
+        this.dispatchEvent(new CustomEvent("vpu-fileupload-file-start",
+            { "detail": data, bubbles: true, composed: true }));
+    }
+
+    /**
+     * @param file
+     * @returns {Promise<number>} key of the queued item
+     */
+    async queueFile(file) {
+        this._queueKey++;
+        const key = this._queueKey;
+        this.queuedFiles[key] = file;
+        this.updateQueuedFilesCount();
+
+        const data = {"file": file};
+        const event = new CustomEvent("vpu-fileupload-file-queued", { "detail": data, bubbles: true, composed: true });
+        this.dispatchEvent(event);
+
+        return key;
+    }
+
+    /**
+     * Takes a file off of the queue
+     *
+     * @param key
+     */
+    takeFileFromQueue(key) {
+        const file = this.queuedFiles[key];
+        delete this.queuedFiles[key];
+        this.updateQueuedFilesCount();
+
+        return file;
+    }
+
+    uploadOneQueuedFile() {
+        const file = this.takeFileFromQueue();
+
+        return this.uploadFile(file);
+    }
+
+    getQueuedFile(key) {
+        return this.queuedFiles[key];
+    }
+
+    getQueuedFiles() {
+        return this.queuedFiles;
+    }
+
+    clearQueuedFiles() {
+        this.queuedFiles = [];
+        this.queuedFilesCount = 0;
+    }
+
+    updateQueuedFilesCount() {
+        return this.queuedFilesCount = Object.keys(this.queuedFiles).length;
+    }
+
+    getQueuedFilesCount() {
+        return this.queuedFilesCount;
+    }
+
+    /**
+     * @param file
+     * @param params
+     * @returns {Promise<void>}
+     */
+    async uploadFile(file, params = {}) {
+        this.uploadInProgress = true;
+        this.sendStartEvent(file);
+        let url = new URL(this.url)
+        url.search = new URLSearchParams(params).toString();
+        let formData = new FormData();
+        formData.append('file', file);
+
+        // I got a 60s timeout in Google Chrome and found no way to increase that
+        await fetch(url, {
+            method: 'POST',
+            headers: {
+                'Authorization': 'Bearer ' + window.VPUAuthToken,
+            },
+            body: formData
+        })
+            .then((response) => {
+                /* Done. Inform the user */
+                console.log(`Status: ${response.status} for file ${file.name}`);
+                this.sendFinishedEvent(response, file, response.status !== 201 || this.alwaysSendFile);
+            })
+            .catch((response) => {
+                /* Error. Inform the user */
+                console.log(`Error status: ${response.status} for file ${file.name}`);
+                this.sendFinishedEvent(response, file, true);
+            });
+
+        this.uploadInProgress = false;
+    }
+
+    static get styles() {
+        // language=css
+        return css`
+            ${commonStyles.getGeneralCSS()}
+            ${commonStyles.getButtonCSS()}
+
+            #dropArea {
+                border: var(--FUBorderWidth, 2px) var(--FUBorderStyle, dashed) var(--FUBBorderColor, black);
+                border-radius: var(--FUBorderRadius, 0);
+                width: var(--FUWidth, auto);
+                margin: var(--FUMargin, 0px);
+                padding: var(--FUPadding, 20px);
+            }
+
+            #dropArea.highlight {
+                border-color: var(--FUBorderColorHighlight, purple);
+            }
+
+            p {
+                margin-top: 0;
+            }
+
+            #fileElem {
+                display: none;
+            }
+
+            #nextcloud-file-picker {
+                display: inline-block;
+                margin-left: 8px;
+            }
+
+            #nextcloud-file-picker.hidden {
+                display: none;
+            }
+
+            .block {
+                margin-bottom: 10px;
+            }
+        `;
+    }
+
+    render() {
+        let allowedMimeTypes = this.allowedMimeTypes;
+
+        if (this.decompressZip) {
+            allowedMimeTypes += ",application/zip";
+        }
+
+        return html`
+            <div id="dropArea">
+                <div title="${this.uploadInProgress ? i18n.t('upload-disabled-title') : ''}">
+                    <div class="block">
+                        ${this.text || i18n.t('intro')}
+                    </div>
+                    <input ?disabled="${this.uploadInProgress || this.disabled}"
+                           type="file"
+                           id="fileElem"
+                           multiple
+                           accept="${mimeTypesToAccept(allowedMimeTypes)}"
+                           name='file'>
+                    <label class="button is-primary" for="fileElem" ?disabled="${this.disabled}">
+                        <vpu-icon style="display: ${this.uploadInProgress ? "inline-block" : "none"}" name="lock"></vpu-icon>
+                        ${this.buttonLabel || i18n.t('upload-label')}
+                    </label>
+                    <vpu-nextcloud-file-picker id="nextcloud-file-picker"
+                                               class="${classMap({hidden: this.nextcloudWebDavUrl === "" || this.nextcloudAuthUrl === ""})}"
+                                               ?disabled="${this.uploadInProgress || this.disabled}"
+                                               lang="${this.lang}"
+                                               auth-url="${this.nextcloudAuthUrl}"
+                                               web-dav-url="${this.nextcloudWebDavUrl}"
+                                               @vpu-nextcloud-file-picker-file-downloaded="${(event) => {
+                                                   this.queueFile(event.detail.file);
+                                               }}"></vpu-nextcloud-file-picker>
+                    <vpu-mini-spinner style="display: ${this.multipleUploadInProgress ? "inline-block" : "none"}"></vpu-mini-spinner>
+                </div>
+            </div>
+        `;
+    }
+}
\ No newline at end of file
diff --git a/packages/file-handling/src/vpu-fileupload.js b/packages/file-handling/src/vpu-fileupload.js
new file mode 100644
index 0000000000000000000000000000000000000000..d1b827890f4c80ee24ff83cefa7244f36e87918c
--- /dev/null
+++ b/packages/file-handling/src/vpu-fileupload.js
@@ -0,0 +1,4 @@
+import * as commonUtils from "vpu-common/utils";
+import {FileUpload} from './fileupload';
+
+commonUtils.defineCustomElement('vpu-fileupload', FileUpload);