diff --git a/CHANGELOG.md b/CHANGELOG.md
index 935ba8c3b900caf74fd0fe29acd75d08e6e10ac6..793f0fc744fa3971ed45868a26c4987e03c88add 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added ###
 
+* File Upload component from @indiscale/caosdb-webui-core-components
 * Map component @indiscale/caosdb-webui-ext-map
 
 ### Changed ###
diff --git a/package.json b/package.json
index a13ecceda6a59729629f9750b624cb7f79804d09..070bccec473e6c1bf3f0a68647bbf08a1609c6d4 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,9 @@
 {
   "dependencies": {
-    "@indiscale/caosdb-webui-core-components": "0.0.8",
+    "@indiscale/caosdb-webui-core-components": "0.0.9",
     "@indiscale/caosdb-webui-ext-map": "file:../caosdb-webui-ext-map/indiscale-caosdb-webui-ext-map-0.1.0.tgz",
     "react": "^18.2.0",
-    "react-bootstrap": "^2.7.2",
+    "react-bootstrap": "^2.8.0",
     "react-dom": "^18.2.0"
   },
   "name": "@indiscale/caosdb-webui-legacy-adapter",
diff --git a/src/file-upload.js b/src/file-upload.js
new file mode 100644
index 0000000000000000000000000000000000000000..d2b45b6088cd45c8b7f9a4207a87be00de26f7c7
--- /dev/null
+++ b/src/file-upload.js
@@ -0,0 +1,340 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import "regenerator-runtime/runtime";
+import { FileUpload } from "@indiscale/caosdb-webui-core-components";
+
+const get_filesystem_body = () => {
+  return document.querySelector("#caosdb-f-filesystem .card-body");
+};
+
+const get_filesystem_header = () => {
+  return document.querySelector("#caosdb-f-filesystem .card-header");
+};
+
+/**
+ * Return an array of the currently shown directory (file system view).
+ */
+const get_current_directory = () => {
+  const header = get_filesystem_header();
+  if (!header) {
+    return [];
+  }
+  return Array.from(header.getElementsByClassName("caosdb-fs-cwd")).map(
+    (el) => el.textContent,
+  );
+};
+
+/**
+ * Construct the XML payload for file insertion.
+ */
+const create_file_upload_request_xml = (files, targetDirectory, recordType) => {
+  const _parent = recordType ? `<Parent id="${recordType}"/>` : "";
+  const request =
+    "<Request>" +
+    files.map((f, idx) => {
+      return `<File upload="${idx}-${f.name}" path="${targetDirectory}${f.name}">${_parent}</File>`;
+    }) +
+    "</Request>";
+
+  return request;
+};
+
+/**
+ * Put all the XML payload and the files into a form data object (for upload).
+ */
+const create_form_data = (xml, files) => {
+  const formData = new FormData();
+  formData.append("FileRepresentation", xml);
+  files.forEach((f, idx) => {
+    const name = `${idx}-${f.name}`;
+    formData.append(name, f.file, name);
+  });
+  return formData;
+};
+
+/**
+ * Split up the link to the file in the entity view. The two new links point to
+ * the file (for download) and to the parent directory (for browsing the file
+ * system).
+ */
+const split_parent_dir = (node) => {
+  repair_uri(node, "href");
+  const path_old = node
+    .getAttribute("href")
+    .substring(connection.getFileSystemPath().length)
+    .split("/");
+  if (path_old.length > 1) {
+    const parent_dir = path_old.slice(0, path_old.length - 1);
+    const parent_dir_node = node.cloneNode();
+    parent_dir_node.setAttribute(
+      "href",
+      connection.getFileSystemPath() + parent_dir.join("/") + "/",
+    );
+    parent_dir_node.textContent = parent_dir.join("/") + "/";
+    parent_dir_node.title = "Go to parent directory.";
+    node.before(parent_dir_node);
+    node.dataset.entityPath = node.textContent;
+    node.textContent = node.textContent.split("/").slice(-1)[0];
+    node.title = "Download this file";
+  }
+};
+
+/**
+ * Repairs broken links (e.g. when files have strange names)
+ */
+const repair_uri = (node, attr) => {
+  const path_old = node
+    .getAttribute(attr)
+    .substring(connection.getFileSystemPath().length)
+    .split("/");
+  const path_new = path_old.map(encodeURIComponent);
+  node.setAttribute(attr, connection.getFileSystemPath() + path_new.join("/"));
+};
+
+/**
+ * Perform the actual AJAX request (returns Promise).
+ */
+const uploadRequest = (formData) => {
+  return $.ajax({
+    url: connection.getBasePath() + "Entity/",
+    method: "POST",
+    dataType: "xml",
+    contentType: false,
+    processData: false,
+    data: formData,
+  });
+};
+
+/**
+ * This onSubmit implementation works for the legacy REST API.
+ */
+const defaultOnSubmit = async (data) => {
+  document.querySelector("#collapseFileUpload~ul")?.remove();
+  var { files, directory, recordType } = data;
+  directory = directory || [];
+  directory = directory.length > 0 ? "/" + directory.join("/") + "/" : "/";
+
+  const xml = create_file_upload_request_xml(files, directory, recordType);
+  const formData = create_form_data(xml, files);
+  const response = await uploadRequest(formData);
+
+  const iterator = response.evaluate(
+    "/Response/File",
+    response,
+    null,
+    XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
+  );
+  const newFiles = [];
+
+  var next = iterator.iterateNext();
+  var hasErrors = false;
+  while (next) {
+    var error = response
+      .evaluate("Error", next, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE)
+      ?.iterateNext();
+    const path = response.evaluate(
+      "@path",
+      next,
+      null,
+      XPathResult.STRING_TYPE,
+    )?.stringValue;
+    const id = response.evaluate(
+      "@id",
+      next,
+      null,
+      XPathResult.STRING_TYPE,
+    )?.stringValue;
+    if (error) {
+      hasErrors = true;
+      error = response.evaluate(
+        "@description",
+        error,
+        null,
+        XPathResult.STRING_TYPE,
+      )?.stringValue;
+    }
+    newFiles.push({
+      error: error,
+      id: id,
+      path: path,
+      url: !id || connection.getBasePath() + "Entity/" + id,
+    });
+    next = iterator.iterateNext();
+  }
+  return {
+    hasErrors: hasErrors,
+    url: connection.getBasePath() + "FileSystem/" + directory,
+    directory: directory,
+    newFiles: newFiles,
+  };
+};
+
+/**
+ * Suitable for our purpose here means: Permission to USE:AS_PARENT is granted
+ * and the RecordType doesn't have any obligatory properties.
+ */
+const findSuitableRecordTypes = async () => {
+  const response = await connection.get("Entity/?query=FIND RECORDTYPE");
+  const iterator = response.evaluate(
+    "/Response/RecordType",
+    response,
+    null,
+    XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
+  );
+  const results = [];
+
+  var n;
+  while (true) {
+    n = iterator.iterateNext();
+    if (!n) {
+      break;
+    }
+    const hasUsePermission = response
+      .evaluate(
+        "Permissions/Permission[@name='USE:AS_PARENT']",
+        n,
+        null,
+        XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
+      )
+      .iterateNext();
+    if (!hasUsePermission) {
+      continue;
+    }
+    const hasObligatoryProperties = response
+      .evaluate(
+        "Property[@importance='OBLIGATORY']",
+        n,
+        null,
+        XPathResult.UNORDERED_NODE_ITERATOR_TYPE,
+      )
+      .iterateNext();
+    if (hasObligatoryProperties) {
+      continue;
+    }
+    results.push({
+      name: response.evaluate("@name", n, null, XPathResult.STRING_TYPE)
+        ?.stringValue,
+      id: response.evaluate("@id", n, null, XPathResult.STRING_TYPE)
+        ?.stringValue,
+      description: response.evaluate(
+        "@description",
+        n,
+        null,
+        XPathResult.STRING_TYPE,
+      )?.stringValue,
+    });
+  }
+  return results;
+};
+
+const file_upload = {
+  get_record_types: async function () {
+    // TODO add permissions to GRPC API, then use this again
+    //const service = new TransactionService();
+    //const results = await service.executeQuery("FIND RECORDTYPE");
+    //console.log(results);
+    return await findSuitableRecordTypes();
+  },
+  create_file_upload_widget: function (container, config) {
+    const root = ReactDOM.createRoot(container);
+    const _config = config || {};
+    if (!_config.hasOwnProperty("onSubmit")) {
+      _config.onSubmit = defaultOnSubmit;
+    }
+
+    if (!_config.hasOwnProperty("recordTypes")) {
+      _config.recordTypes = this.get_record_types().then((results) =>
+        results.map((rt) => {
+          return { label: rt.name, value: rt.id, title: rt.description };
+        }),
+      );
+    }
+
+    root.render(
+      <React.StrictMode>
+        <form>
+          <FileUpload {..._config} />
+        </form>
+      </React.StrictMode>,
+    );
+  },
+
+  /**
+   * This is the "+" button shown in the file system view.
+   */
+  create_upload_button: () => {
+    const button = $(`
+      <button class="py-0 px-1 ms-2 btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFileUpload" aria-expanded="false" aria-controls="collapseFileUpload">
+        <span class="bi bi-plus-lg"/>
+      </button>`);
+
+    const _onClick = () => {
+      button[0].classList.add("d-none");
+    };
+    button.on("click", _onClick);
+    return button[0];
+  },
+
+  init_file_upload: function () {
+    const container = $(
+      `<div class="row collapse mb-5" id="collapseFileUpload"/>`,
+    );
+    const filesystem_header =
+      get_filesystem_header()?.querySelector("div.text-end");
+    const filesystem_body = get_filesystem_body();
+
+    if (filesystem_body && filesystem_header) {
+      const button = this.create_upload_button();
+      filesystem_header.appendChild(button);
+
+      $(filesystem_body).prepend(container);
+
+      const config = {
+        directoryReadOnly: false,
+        directoryBase: get_current_directory(),
+        onCancel: () => {
+          button.click();
+          button.classList.remove("d-none");
+        },
+        onFinish: () => {
+          window.location.reload();
+        },
+      };
+      this.create_file_upload_widget(container[0], config);
+    }
+  },
+
+  repair_file_system_links: function () {
+    // entity view
+    document
+      .querySelectorAll(".caosdb-entity-heading-attr a")
+      .forEach(split_parent_dir);
+
+    // filesystem view
+    document
+      .querySelectorAll("img.entity-image-preview")
+      .forEach((node) => repair_uri(node, "src"));
+    document
+      .querySelectorAll("a.caosdb-fs-file")
+      .forEach((node) => repair_uri(node, "href"));
+    document
+      .querySelectorAll("a.caosdb-fs-dir")
+      .forEach((node) => repair_uri(node, "href"));
+  },
+
+  init: async function () {
+    this.init_file_upload();
+    this.repair_file_system_links();
+
+    // this object can be used to create a widget, e.g. for creating custom
+    // upload forms.
+    window.caosdb_file_upload_widget = this;
+  },
+};
+
+$(document).ready(function () {
+  const build = window.BUILD_MODULE_EXT_FILE_UPLOAD || "${BUILD_MODULE_EXT_FILE_UPLOAD}"
+  if (build === "ENABLED") {
+    caosdb_modules.register(file_upload);
+  }
+});
diff --git a/src/map.js b/src/map.js
index 4434b480b6fbbd055b349e9bef47a2059e65782c..f4e93ee11ee41cfb2610bfeef5dbeb8009363211 100644
--- a/src/map.js
+++ b/src/map.js
@@ -1,7 +1,5 @@
 import React from "react";
 import ReactDOM from "react-dom/client";
-import "regenerator-runtime/runtime";
-import { TransactionService } from "@indiscale/caosdb-webui-entity-service";
 import { Map, ToggleMapButton } from "@indiscale/caosdb-webui-ext-map";
 import { queryCallback } from "./queryCallback";
 
diff --git a/webpack.config.js b/webpack.config.js
index d1b7db4d607faedf8e843f97723037302323b1ec..a922a91450151ed0cdbf81e3a065d7a67c1543e5 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -6,6 +6,7 @@ module.exports = {
   entry: {
     'query-form' : './src/query-form.js',
     'map' : './src/map.js',
+    'file-upload': './src/file-upload.js',
   },
   output: {
     path: path.resolve(__dirname, 'build'),