diff --git a/app/controllers/stock_images.php b/app/controllers/stock_images.php
new file mode 100644
index 0000000000000000000000000000000000000000..b272a71e9a760b54ed1d22ff75aa3f78febe7d3b
--- /dev/null
+++ b/app/controllers/stock_images.php
@@ -0,0 +1,37 @@
+<?php
+
+class StockImagesController extends AuthenticatedController
+{
+    /**
+     * Common tasks for all actions.
+     */
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        URLHelper::removeLinkParam('cid');
+        $GLOBALS['perm']->check('admin');
+
+        if (Navigation::hasItem('/contents/stock_images')) {
+            Navigation::activateItem('/contents/stock_images');
+        }
+        \PageLayout::setTitle(_('Verwaltung des Bilder-Pools'));
+        $this->setSidebar();
+    }
+
+    /**
+     * Administration view for banner
+     */
+    public function index_action(): void
+    {
+    }
+
+    /**
+     * Setup the sidebar
+     */
+    protected function setSidebar(): void
+    {
+        $sidebar = \Sidebar::Get();
+        $sidebar->addWidget(new \VueWidget('stock-images-widget'));
+    }
+}
diff --git a/app/views/stock_images/index.php b/app/views/stock_images/index.php
new file mode 100644
index 0000000000000000000000000000000000000000..d07c9d7be078bd914ca21d24c526c652d6d0d35d
--- /dev/null
+++ b/app/views/stock_images/index.php
@@ -0,0 +1 @@
+<div class="stock-images"></div>
diff --git a/cli/studip b/cli/studip
index d830e31f7600094c8f59c4b22b55cf8513c5982d..ced9f0dfffb9c971aab69635f0729a7555d2a823 100755
--- a/cli/studip
+++ b/cli/studip
@@ -65,7 +65,7 @@ $commands = [
     Commands\User\GetUser::class,
 ];
 $creator = function ($command) {
-    return new $command();
+    return app($command);
 };
 $application->addCommands(array_map($creator, $commands));
 $application->run();
diff --git a/composer.json b/composer.json
index 101d77035be78397312c2ca53f5b5b8697450b07..413e23c38f44f996445b14f82bef5ef39bc3db90 100644
--- a/composer.json
+++ b/composer.json
@@ -59,7 +59,8 @@
         "symfony/polyfill-php74": "^1.27",
         "symfony/polyfill-php80": "^1.27",
         "symfony/polyfill-php81": "^1.27",
-        "phpowermove/docblock": "^2.0"
+        "phpowermove/docblock": "^2.0",
+        "ksubileau/color-thief-php": "^2.0"
     },
     "replace": {
         "symfony/polyfill-php54": "*",
diff --git a/composer.lock b/composer.lock
index 5119b01ee68700439a41095e83772c451997de76..a6d76227b28078427565f4c319c6acb11bcbd92e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "b208b63a6fc53d453c263420fa7350da",
+    "content-hash": "c4da295f5930f9bfa0b1c4014a12f5b3",
     "packages": [
         {
             "name": "algo26-matthias/idna-convert",
@@ -413,22 +413,22 @@
         },
         {
             "name": "guzzlehttp/psr7",
-            "version": "2.4.4",
+            "version": "2.5.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/psr7.git",
-                "reference": "3cf1b6d4f0c820a2cf8bcaec39fc698f3443b5cf"
+                "reference": "b635f279edd83fc275f822a1188157ffea568ff6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/psr7/zipball/3cf1b6d4f0c820a2cf8bcaec39fc698f3443b5cf",
-                "reference": "3cf1b6d4f0c820a2cf8bcaec39fc698f3443b5cf",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/b635f279edd83fc275f822a1188157ffea568ff6",
+                "reference": "b635f279edd83fc275f822a1188157ffea568ff6",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.2.5 || ^8.0",
                 "psr/http-factory": "^1.0",
-                "psr/http-message": "^1.0",
+                "psr/http-message": "^1.1 || ^2.0",
                 "ralouphie/getallheaders": "^3.0"
             },
             "provide": {
@@ -448,9 +448,6 @@
                 "bamarni-bin": {
                     "bin-links": true,
                     "forward-command": false
-                },
-                "branch-alias": {
-                    "dev-master": "2.4-dev"
                 }
             },
             "autoload": {
@@ -512,7 +509,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/psr7/issues",
-                "source": "https://github.com/guzzle/psr7/tree/2.4.4"
+                "source": "https://github.com/guzzle/psr7/tree/2.5.0"
             },
             "funding": [
                 {
@@ -528,7 +525,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-03-09T13:19:02+00:00"
+            "time": "2023-04-17T16:11:26+00:00"
         },
         {
             "name": "jakeasmith/http_build_url",
@@ -569,16 +566,16 @@
         },
         {
             "name": "jasig/phpcas",
-            "version": "1.5.0",
+            "version": "1.6.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/apereo/phpCAS.git",
-                "reference": "d6f5797fb568726f34c8e48741776d81e4a2646b"
+                "reference": "c129708154852656aabb13d8606cd5b12dbbabac"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/apereo/phpCAS/zipball/d6f5797fb568726f34c8e48741776d81e4a2646b",
-                "reference": "d6f5797fb568726f34c8e48741776d81e4a2646b",
+                "url": "https://api.github.com/repos/apereo/phpCAS/zipball/c129708154852656aabb13d8606cd5b12dbbabac",
+                "reference": "c129708154852656aabb13d8606cd5b12dbbabac",
                 "shasum": ""
             },
             "require": {
@@ -599,6 +596,9 @@
                 }
             },
             "autoload": {
+                "files": [
+                    "source/CAS.php"
+                ],
                 "classmap": [
                     "source/"
                 ]
@@ -631,9 +631,9 @@
             ],
             "support": {
                 "issues": "https://github.com/apereo/phpCAS/issues",
-                "source": "https://github.com/apereo/phpCAS/tree/1.5.0"
+                "source": "https://github.com/apereo/phpCAS/tree/1.6.1"
             },
-            "time": "2022-05-03T21:12:54+00:00"
+            "time": "2023-02-19T19:52:35+00:00"
         },
         {
             "name": "jumbojett/openid-connect-php",
@@ -677,6 +677,66 @@
             },
             "time": "2022-09-30T12:34:46+00:00"
         },
+        {
+            "name": "ksubileau/color-thief-php",
+            "version": "v2.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ksubileau/color-thief-php.git",
+                "reference": "a1378533433dae824c2e5b70359f8b6ae16b1deb"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ksubileau/color-thief-php/zipball/a1378533433dae824c2e5b70359f8b6ae16b1deb",
+                "reference": "a1378533433dae824c2e5b70359f8b6ae16b1deb",
+                "shasum": ""
+            },
+            "require": {
+                "ext-fileinfo": "*",
+                "php": "^7.2|^8.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^3.4",
+                "phpstan/phpstan": "^1.2.0",
+                "phpstan/phpstan-phpunit": "^1.0.0",
+                "phpunit/phpunit": "^8.5"
+            },
+            "suggest": {
+                "ext-gd": "to use the GD image adapter.",
+                "ext-gmagick": "to use the Gmagick image adapter.",
+                "ext-imagick": "to use the Imagick image adapter."
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "ColorThief\\": "src/ColorThief"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Kevin Subileau",
+                    "homepage": "http://www.kevinsubileau.fr"
+                }
+            ],
+            "description": "Grabs the dominant color or a representative color palette from an image.",
+            "homepage": "http://www.kevinsubileau.fr/projets/color-thief-php",
+            "keywords": [
+                "color",
+                "dominant",
+                "palette",
+                "php",
+                "thief"
+            ],
+            "support": {
+                "issues": "https://github.com/ksubileau/color-thief-php/issues",
+                "source": "https://github.com/ksubileau/color-thief-php/tree/v2.0.1"
+            },
+            "time": "2022-11-12T10:09:40+00:00"
+        },
         {
             "name": "kub-at/php-simple-html-dom-parser",
             "version": "1.9.1",
@@ -2279,21 +2339,21 @@
         },
         {
             "name": "psr/http-factory",
-            "version": "1.0.1",
+            "version": "1.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/http-factory.git",
-                "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be"
+                "reference": "e616d01114759c4c489f93b099585439f795fe35"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
-                "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35",
+                "reference": "e616d01114759c4c489f93b099585439f795fe35",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.0.0",
-                "psr/http-message": "^1.0"
+                "psr/http-message": "^1.0 || ^2.0"
             },
             "type": "library",
             "extra": {
@@ -2313,7 +2373,7 @@
             "authors": [
                 {
                     "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
+                    "homepage": "https://www.php-fig.org/"
                 }
             ],
             "description": "Common interfaces for PSR-7 HTTP message factories",
@@ -2328,31 +2388,31 @@
                 "response"
             ],
             "support": {
-                "source": "https://github.com/php-fig/http-factory/tree/master"
+                "source": "https://github.com/php-fig/http-factory/tree/1.0.2"
             },
-            "time": "2019-04-30T12:38:16+00:00"
+            "time": "2023-04-10T20:10:41+00:00"
         },
         {
             "name": "psr/http-message",
-            "version": "1.0.1",
+            "version": "1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/http-message.git",
-                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
+                "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
-                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
+                "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
+                "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.0"
+                "php": "^7.2 || ^8.0"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.0.x-dev"
+                    "dev-master": "1.1.x-dev"
                 }
             },
             "autoload": {
@@ -2381,27 +2441,27 @@
                 "response"
             ],
             "support": {
-                "source": "https://github.com/php-fig/http-message/tree/master"
+                "source": "https://github.com/php-fig/http-message/tree/1.1"
             },
-            "time": "2016-08-06T14:39:51+00:00"
+            "time": "2023-04-04T09:50:52+00:00"
         },
         {
             "name": "psr/http-server-handler",
-            "version": "1.0.1",
+            "version": "1.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/http-server-handler.git",
-                "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7"
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/aff2f80e33b7f026ec96bb42f63242dc50ffcae7",
-                "reference": "aff2f80e33b7f026ec96bb42f63242dc50ffcae7",
+                "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.0",
-                "psr/http-message": "^1.0"
+                "psr/http-message": "^1.0 || ^2.0"
             },
             "type": "library",
             "extra": {
@@ -2421,7 +2481,7 @@
             "authors": [
                 {
                     "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
+                    "homepage": "https://www.php-fig.org/"
                 }
             ],
             "description": "Common interface for HTTP server-side request handler",
@@ -2437,28 +2497,27 @@
                 "server"
             ],
             "support": {
-                "issues": "https://github.com/php-fig/http-server-handler/issues",
-                "source": "https://github.com/php-fig/http-server-handler/tree/master"
+                "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
             },
-            "time": "2018-10-30T16:46:14+00:00"
+            "time": "2023-04-10T20:06:20+00:00"
         },
         {
             "name": "psr/http-server-middleware",
-            "version": "1.0.1",
+            "version": "1.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/http-server-middleware.git",
-                "reference": "2296f45510945530b9dceb8bcedb5cb84d40c5f5"
+                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/2296f45510945530b9dceb8bcedb5cb84d40c5f5",
-                "reference": "2296f45510945530b9dceb8bcedb5cb84d40c5f5",
+                "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
+                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.0",
-                "psr/http-message": "^1.0",
+                "psr/http-message": "^1.0 || ^2.0",
                 "psr/http-server-handler": "^1.0"
             },
             "type": "library",
@@ -2479,7 +2538,7 @@
             "authors": [
                 {
                     "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
+                    "homepage": "https://www.php-fig.org/"
                 }
             ],
             "description": "Common interface for HTTP server-side middleware",
@@ -2495,9 +2554,9 @@
             ],
             "support": {
                 "issues": "https://github.com/php-fig/http-server-middleware/issues",
-                "source": "https://github.com/php-fig/http-server-middleware/tree/master"
+                "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
             },
-            "time": "2018-10-30T17:12:04+00:00"
+            "time": "2023-04-11T06:14:47+00:00"
         },
         {
             "name": "psr/log",
@@ -2945,16 +3004,16 @@
         },
         {
             "name": "symfony/console",
-            "version": "v5.4.21",
+            "version": "v5.4.22",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "c77433ddc6cdc689caf48065d9ea22ca0853fbd9"
+                "reference": "3cd51fd2e6c461ca678f84d419461281bd87a0a8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/c77433ddc6cdc689caf48065d9ea22ca0853fbd9",
-                "reference": "c77433ddc6cdc689caf48065d9ea22ca0853fbd9",
+                "url": "https://api.github.com/repos/symfony/console/zipball/3cd51fd2e6c461ca678f84d419461281bd87a0a8",
+                "reference": "3cd51fd2e6c461ca678f84d419461281bd87a0a8",
                 "shasum": ""
             },
             "require": {
@@ -3019,12 +3078,12 @@
             "homepage": "https://symfony.com",
             "keywords": [
                 "cli",
-                "command line",
+                "command-line",
                 "console",
                 "terminal"
             ],
             "support": {
-                "source": "https://github.com/symfony/console/tree/v5.4.21"
+                "source": "https://github.com/symfony/console/tree/v5.4.22"
             },
             "funding": [
                 {
@@ -3040,7 +3099,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-02-25T16:59:41+00:00"
+            "time": "2023-03-25T09:27:28+00:00"
         },
         {
             "name": "symfony/deprecation-contracts",
@@ -3679,16 +3738,16 @@
         },
         {
             "name": "symfony/process",
-            "version": "v5.4.21",
+            "version": "v5.4.22",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/process.git",
-                "reference": "d4ce417ebcb0b7d090b4c178ed6d3accc518e8bd"
+                "reference": "4b850da0cc3a2a9181c1ed407adbca4733dc839b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/process/zipball/d4ce417ebcb0b7d090b4c178ed6d3accc518e8bd",
-                "reference": "d4ce417ebcb0b7d090b4c178ed6d3accc518e8bd",
+                "url": "https://api.github.com/repos/symfony/process/zipball/4b850da0cc3a2a9181c1ed407adbca4733dc839b",
+                "reference": "4b850da0cc3a2a9181c1ed407adbca4733dc839b",
                 "shasum": ""
             },
             "require": {
@@ -3721,7 +3780,7 @@
             "description": "Executes commands in sub-processes",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/process/tree/v5.4.21"
+                "source": "https://github.com/symfony/process/tree/v5.4.22"
             },
             "funding": [
                 {
@@ -3737,7 +3796,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-02-21T19:46:44+00:00"
+            "time": "2023-03-06T21:29:33+00:00"
         },
         {
             "name": "symfony/service-contracts",
@@ -3824,16 +3883,16 @@
         },
         {
             "name": "symfony/string",
-            "version": "v5.4.21",
+            "version": "v5.4.22",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/string.git",
-                "reference": "edac10d167b78b1d90f46a80320d632de0bd9f2f"
+                "reference": "8036a4c76c0dd29e60b6a7cafcacc50cf088ea62"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/string/zipball/edac10d167b78b1d90f46a80320d632de0bd9f2f",
-                "reference": "edac10d167b78b1d90f46a80320d632de0bd9f2f",
+                "url": "https://api.github.com/repos/symfony/string/zipball/8036a4c76c0dd29e60b6a7cafcacc50cf088ea62",
+                "reference": "8036a4c76c0dd29e60b6a7cafcacc50cf088ea62",
                 "shasum": ""
             },
             "require": {
@@ -3890,7 +3949,7 @@
                 "utf8"
             ],
             "support": {
-                "source": "https://github.com/symfony/string/tree/v5.4.21"
+                "source": "https://github.com/symfony/string/tree/v5.4.22"
             },
             "funding": [
                 {
@@ -3906,7 +3965,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-02-22T08:00:55+00:00"
+            "time": "2023-03-14T06:11:53+00:00"
         },
         {
             "name": "symfony/yaml",
@@ -5223,34 +5282,29 @@
         },
         {
             "name": "php-http/httplug",
-            "version": "2.3.0",
+            "version": "2.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-http/httplug.git",
-                "reference": "f640739f80dfa1152533976e3c112477f69274eb"
+                "reference": "625ad742c360c8ac580fcc647a1541d29e257f67"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-http/httplug/zipball/f640739f80dfa1152533976e3c112477f69274eb",
-                "reference": "f640739f80dfa1152533976e3c112477f69274eb",
+                "url": "https://api.github.com/repos/php-http/httplug/zipball/625ad742c360c8ac580fcc647a1541d29e257f67",
+                "reference": "625ad742c360c8ac580fcc647a1541d29e257f67",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.1 || ^8.0",
                 "php-http/promise": "^1.1",
                 "psr/http-client": "^1.0",
-                "psr/http-message": "^1.0"
+                "psr/http-message": "^1.0 || ^2.0"
             },
             "require-dev": {
-                "friends-of-phpspec/phpspec-code-coverage": "^4.1",
-                "phpspec/phpspec": "^5.1 || ^6.0"
+                "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0",
+                "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0"
             },
             "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.x-dev"
-                }
-            },
             "autoload": {
                 "psr-4": {
                     "Http\\Client\\": "src/"
@@ -5279,9 +5333,9 @@
             ],
             "support": {
                 "issues": "https://github.com/php-http/httplug/issues",
-                "source": "https://github.com/php-http/httplug/tree/2.3.0"
+                "source": "https://github.com/php-http/httplug/tree/2.4.0"
             },
-            "time": "2022-02-21T09:52:22+00:00"
+            "time": "2023-04-14T15:10:03+00:00"
         },
         {
             "name": "php-http/promise",
@@ -5342,16 +5396,16 @@
         },
         {
             "name": "phpstan/phpstan",
-            "version": "1.10.8",
+            "version": "1.10.14",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "0166aef76e066f0dd2adc2799bdadfa1635711e9"
+                "reference": "d232901b09e67538e5c86a724be841bea5768a7c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0166aef76e066f0dd2adc2799bdadfa1635711e9",
-                "reference": "0166aef76e066f0dd2adc2799bdadfa1635711e9",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d232901b09e67538e5c86a724be841bea5768a7c",
+                "reference": "d232901b09e67538e5c86a724be841bea5768a7c",
                 "shasum": ""
             },
             "require": {
@@ -5400,7 +5454,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-03-24T10:28:16+00:00"
+            "time": "2023-04-19T13:47:27+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
@@ -5848,21 +5902,21 @@
         },
         {
             "name": "psr/http-client",
-            "version": "1.0.1",
+            "version": "1.0.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/http-client.git",
-                "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621"
+                "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
-                "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+                "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31",
+                "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.0 || ^8.0",
-                "psr/http-message": "^1.0"
+                "psr/http-message": "^1.0 || ^2.0"
             },
             "type": "library",
             "extra": {
@@ -5882,7 +5936,7 @@
             "authors": [
                 {
                     "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
+                    "homepage": "https://www.php-fig.org/"
                 }
             ],
             "description": "Common interface for HTTP clients",
@@ -5894,9 +5948,9 @@
                 "psr-18"
             ],
             "support": {
-                "source": "https://github.com/php-fig/http-client/tree/master"
+                "source": "https://github.com/php-fig/http-client/tree/1.0.2"
             },
-            "time": "2020-06-29T06:28:15+00:00"
+            "time": "2023-04-10T20:12:12+00:00"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",
@@ -6695,16 +6749,16 @@
         },
         {
             "name": "symfony/event-dispatcher",
-            "version": "v5.4.21",
+            "version": "v5.4.22",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/event-dispatcher.git",
-                "reference": "f0ae1383a8285dfc6752b8d8602790953118ff5a"
+                "reference": "1df20e45d56da29a4b1d8259dd6e950acbf1b13f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f0ae1383a8285dfc6752b8d8602790953118ff5a",
-                "reference": "f0ae1383a8285dfc6752b8d8602790953118ff5a",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1df20e45d56da29a4b1d8259dd6e950acbf1b13f",
+                "reference": "1df20e45d56da29a4b1d8259dd6e950acbf1b13f",
                 "shasum": ""
             },
             "require": {
@@ -6760,7 +6814,7 @@
             "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.21"
+                "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.22"
             },
             "funding": [
                 {
@@ -6776,7 +6830,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-02-14T08:03:56+00:00"
+            "time": "2023-03-17T11:31:58+00:00"
         },
         {
             "name": "symfony/event-dispatcher-contracts",
@@ -6922,16 +6976,16 @@
         },
         {
             "name": "symfony/var-dumper",
-            "version": "v5.4.21",
+            "version": "v5.4.22",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/var-dumper.git",
-                "reference": "6c5ac3a1be8b849d59a1a77877ee110e1b55eb74"
+                "reference": "e2edac9ce47e6df07e38143c7cfa6bdbc1a6dcc4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6c5ac3a1be8b849d59a1a77877ee110e1b55eb74",
-                "reference": "6c5ac3a1be8b849d59a1a77877ee110e1b55eb74",
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e2edac9ce47e6df07e38143c7cfa6bdbc1a6dcc4",
+                "reference": "e2edac9ce47e6df07e38143c7cfa6bdbc1a6dcc4",
                 "shasum": ""
             },
             "require": {
@@ -6991,7 +7045,7 @@
                 "dump"
             ],
             "support": {
-                "source": "https://github.com/symfony/var-dumper/tree/v5.4.21"
+                "source": "https://github.com/symfony/var-dumper/tree/v5.4.22"
             },
             "funding": [
                 {
@@ -7007,7 +7061,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2023-02-23T10:00:28+00:00"
+            "time": "2023-03-25T09:27:28+00:00"
         },
         {
             "name": "theseer/tokenizer",
@@ -7146,5 +7200,5 @@
     "platform-overrides": {
         "php": "7.2.5"
     },
-    "plugin-api-version": "2.3.0"
+    "plugin-api-version": "2.2.0"
 }
diff --git a/db/migrations/5.4.7_create_stock_images_table.php b/db/migrations/5.4.7_create_stock_images_table.php
new file mode 100644
index 0000000000000000000000000000000000000000..e1a7b04338e625698c90bb5150a6f62b19d423df
--- /dev/null
+++ b/db/migrations/5.4.7_create_stock_images_table.php
@@ -0,0 +1,41 @@
+<?php
+
+class CreateStockImagesTable extends Migration
+{
+    public function description()
+    {
+        return 'create table for stock images';
+    }
+
+    public function up()
+    {
+        $db = DBManager::get();
+        $query =
+            "CREATE TABLE IF NOT EXISTS `stock_images` (
+             `id` int(11) NOT NULL AUTO_INCREMENT,
+
+             `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+             `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+             `license` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+             `author` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+
+             `mime_type` varchar(64) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+             `size` int(10) UNSIGNED NOT NULL,
+             `width` int(10) UNSIGNED NOT NULL,
+             `height` int(10) UNSIGNED NOT NULL,
+             `palette` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+             `tags` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+
+             `mkdate` int(11) UNSIGNED NOT NULL,
+             `chdate` int(11) UNSIGNED NOT NULL,
+
+             PRIMARY KEY (`id`))";
+        $db->exec($query);
+    }
+
+    public function down()
+    {
+        $db = DBManager::get();
+        $db->exec('DROP TABLE IF EXISTS `stock_images`');
+    }
+}
diff --git a/db/migrations/5.4.8_add_image_type_to_structural_elements.php b/db/migrations/5.4.8_add_image_type_to_structural_elements.php
new file mode 100644
index 0000000000000000000000000000000000000000..7a2600a1c12334f9082e77fb8065b9f658caba87
--- /dev/null
+++ b/db/migrations/5.4.8_add_image_type_to_structural_elements.php
@@ -0,0 +1,28 @@
+<?php
+
+class AddImageTypeToStructuralElements extends Migration
+{
+    public function description()
+    {
+        return 'Add field `image_type` to table `cw_structural_elements`';
+    }
+
+    public function up()
+    {
+        $db = DBManager::get();
+        $db->exec(
+            sprintf(
+                'ALTER TABLE `cw_structural_elements` ' .
+                    'ADD `image_type` ENUM("%s", "%s") NOT NULL DEFAULT "%1$s" AFTER `image_id`',
+                \FileRef::class,
+                \StockImage::class
+            )
+        );
+    }
+
+    public function down()
+    {
+        $db = DBManager::get();
+        $db->exec('ALTER TABLE `cw_structural_elements` DROP `image_type`');
+    }
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index f01a0965170db50bd16397d86cd13bc709b7ddcd..9d94c190dd7c4990141eec782221a9c256f1cf96 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -132,6 +132,7 @@ class RouteMap
         $this->addAuthenticatedLtiRoutes($group);
         $this->addAuthenticatedMessagesRoutes($group);
         $this->addAuthenticatedNewsRoutes($group);
+        $this->addAuthenticatedStockImagesRoutes($group);
         $this->addAuthenticatedStudyAreasRoutes($group);
         $this->addAuthenticatedTreeRoutes($group);
         $this->addAuthenticatedWikiRoutes($group);
@@ -597,6 +598,17 @@ class RouteMap
         $group->delete('/forum-entries/{id}', Routes\Forum\ForumEntriesDelete::class);
     }
 
+    private function addAuthenticatedStockImagesRoutes(RouteCollectorProxy $group): void
+    {
+        $group->get('/stock-images', Routes\StockImages\StockImagesIndex::class);
+        $group->post('/stock-images', Routes\StockImages\StockImagesCreate::class);
+        $group->get('/stock-images/{id}', Routes\StockImages\StockImagesShow::class);
+        $group->patch('/stock-images/{id}', Routes\StockImages\StockImagesUpdate::class);
+        $group->delete('/stock-images/{id}', Routes\StockImages\StockImagesDelete::class);
+
+        $group->post('/stock-images/{id}/blob', Routes\StockImages\StockImagesUpload::class);
+    }
+
     private function addRelationship(RouteCollectorProxy $group, string $url, string $handler): void
     {
         $group->map(['GET', 'PATCH', 'POST', 'DELETE'], $url, $handler);
diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageDelete.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageDelete.php
index b8d2a0c132d7488852a817a23295cea58da9e1b6..89549170191b2c4d93cd35f97fcc515c89f3db2e 100644
--- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageDelete.php
+++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageDelete.php
@@ -21,11 +21,13 @@ class StructuralElementsImageDelete extends NonJsonApiController
             throw new AuthorizationFailedException();
         }
 
-        if ($structuralElement->image) {
+        // remove existing image
+        if (is_a($structuralElement->image, \FileRef::class)) {
             $structuralElement->image->getFileType()->delete();
         }
 
         $structuralElement->image_id = null;
+        $structuralElement->image_type = \FileRef::class;
         $structuralElement->store();
 
         return $response->withStatus(204);
diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php
index 3f85d9bb3cf0a60ed90fbb8562190182e1af7c28..aaca497e79764394a6c1140264addd015df56fc0 100644
--- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php
+++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php
@@ -34,12 +34,13 @@ class StructuralElementsImageUpload extends NonJsonApiController
         $fileRef = $this->handleUpload($request, $publicFolder, $structuralElement);
 
         // remove existing image
-        if ($structuralElement->image) {
+        if (is_a($structuralElement->image, \FileRef::class)) {
             $structuralElement->image->getFileType()->delete();
         }
 
         // refer to newly uploaded image
         $structuralElement->image_id = $fileRef->id;
+        $structuralElement->image_type = \FileRef::class;
         $structuralElement->store();
 
         return $response->withStatus(201);
diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsUpdate.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsUpdate.php
index febe3cc8480b4db3c1f94f8efdb967483ec5d54f..6bf0e79b7afc6beb616bcf2587327e2b2cd8a313 100644
--- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsUpdate.php
+++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsUpdate.php
@@ -8,6 +8,8 @@ use JsonApi\Errors\RecordNotFoundException;
 use JsonApi\JsonApiController;
 use JsonApi\Routes\ValidationTrait;
 use JsonApi\Schemas\Courseware\StructuralElement as StructuralElementSchema;
+use JsonApi\Schemas\FileRef as FileRefSchema;
+use JsonApi\Schemas\StockImage as StockImageSchema;
 use Psr\Http\Message\ResponseInterface as Response;
 use Psr\Http\Message\ServerRequestInterface as Request;
 
@@ -83,6 +85,17 @@ class StructuralElementsUpdate extends JsonApiController
                 }
             }
         }
+
+        $imageRelationship = 'data.relationships.' . StructuralElementSchema::REL_IMAGE;
+        if (self::arrayHas($json, $imageRelationship)) {
+            $relation = self::arrayGet($json, $imageRelationship);
+            if (isset($relation['data']['type'])) {
+                $validTypes = [FileRefSchema::TYPE, StockImageSchema::TYPE];
+                if (!in_array($relation['data']['type'], $validTypes)) {
+                    return 'Relationship `image` can only be of type ' . join(', ', $validTypes);
+                }
+            }
+        }
     }
 
     private function getParentFromJson($json)
@@ -114,7 +127,7 @@ class StructuralElementsUpdate extends JsonApiController
 
             foreach ($attributes as $jsonKey) {
                 $sormKey = strtr($jsonKey, '-', '_');
-                if ($val = self::arrayGet($json, 'data.attributes.'.$jsonKey, '')) {
+                if ($val = self::arrayGet($json, 'data.attributes.' . $jsonKey, '')) {
                     $resource->$sormKey = $val;
                 }
             }
@@ -133,10 +146,73 @@ class StructuralElementsUpdate extends JsonApiController
                 $resource->parent_id = $parent->id;
             }
 
+            // update image
+            $this->updateImage($resource, $json);
+
             $resource->editor_id = $user->id;
             $resource->store();
 
             return $resource;
         });
     }
+
+    private function updateImage(StructuralElement $resource, array $json): void
+    {
+        if (!$this->imageNeedsUpdate($resource, $json)) {
+            return;
+        }
+
+        $currentImage = $resource->image;
+        list($imageType, $imageId) = $this->getImageRelationshipData($json);
+
+        // remove current image
+        if (!$imageType && !$imageId) {
+            if (is_a($currentImage, \FileRef::class)) {
+                $currentImage->getFileType()->delete();
+            }
+            $resource->image_id = null;
+            $resource->image_type = null;
+        } elseif ($imageType === StockImageSchema::TYPE) {
+            $stockImageExists = \StockImage::countBySQL('id = ?', [$imageId]);
+            if (!$stockImageExists) {
+                throw new RecordNotFoundException('Could not find that stock image.');
+            }
+            $resource->image_id = $imageId;
+            $resource->image_type = \StockImage::class;
+        } elseif ($imageType === FileRefSchema::TYPE) {
+            throw new \RuntimeException('Not yet implemented.');
+        }
+    }
+
+    private function getImageRelationshipData(array $json): array
+    {
+        $imageRelationship = 'data.relationships.' . StructuralElementSchema::REL_IMAGE;
+        if (!self::arrayHas($json, $imageRelationship)) {
+            throw new \RuntimeException('Missing relationship `image`');
+        }
+        $relation = self::arrayGet($json, $imageRelationship);
+
+        return [self::arrayGet($relation, 'data.type'), self::arrayGet($relation, 'data.id')];
+    }
+
+    private function imageNeedsUpdate(StructuralElement $resource, array $json): bool
+    {
+        $imageRelationship = 'data.relationships.' . StructuralElementSchema::REL_IMAGE;
+        if (!self::arrayHas($json, $imageRelationship)) {
+            return false;
+        }
+
+        $currentImage = $resource->image;
+        list($imageType, $imageId) = $this->getImageRelationshipData($json);
+
+        if (!$currentImage) {
+            return (bool) $imageId;
+        }
+
+        $currentImageSchema = $this->getSchema($currentImage);
+
+        return ($currentImage && !$imageId)
+            || $currentImageSchema::TYPE !== $imageType
+            || $currentImage->id != $imageId;
+    }
 }
diff --git a/lib/classes/JsonApi/Routes/StockImages/Authority.php b/lib/classes/JsonApi/Routes/StockImages/Authority.php
new file mode 100644
index 0000000000000000000000000000000000000000..77851fd1235938a144ee2fd818cbf799ec8d88fd
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/Authority.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use StockImage;
+use User;
+
+class Authority
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canIndexStockImages(User $user): bool
+    {
+        return true;
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canShowStockImage(User $user, StockImage $resource): bool
+    {
+        return true;
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.Superglobals)
+     */
+    public static function canCreateStockImage(User $user): bool
+    {
+        return $GLOBALS['perm']->have_perm('admin', $user->id);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canUpdateStockImage(User $user, StockImage $resource): bool
+    {
+        return self::canCreateStockImage($user);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canUploadStockImage(User $user, StockImage $resource): bool
+    {
+        return self::canCreateStockImage($user);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canDeleteStockImage(User $user, StockImage $resource): bool
+    {
+        return self::canCreateStockImage($user);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesCreate.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesCreate.php
new file mode 100644
index 0000000000000000000000000000000000000000..5c11a9e0466e03f294551478c99f00958037cf0c
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesCreate.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\StockImage as ResourceSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use StockImage;
+use User;
+
+/**
+ * Creates a stock image.
+ */
+class StockImagesCreate extends JsonApiController
+{
+    use ValidationTrait;
+    use ValidationHelpers;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canCreateStockImage($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource = $this->createResource($json);
+
+        return $this->getContentResponse($resource);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+
+        if (ResourceSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Wrong `type` member of document´s `data`.';
+        }
+
+        $errors = iterator_to_array(self::requiredAttributes($json, ['title', 'description', 'author', 'license']));
+        if (!empty($errors)) {
+            return current($errors);
+        }
+
+        // optional attribute `tags` has to be an array
+        if (self::arrayHas($json, 'data.attributes.tags')) {
+            $tags = self::arrayGet($json, 'data.attributes.tags', []);
+            if (!is_array($tags) || !array_is_list($tags)) {
+                return 'Attribute `tags` has to be a list of strings.';
+            }
+        }
+    }
+
+    protected static function requiredAttributes($json, $keys)
+    {
+        foreach ($keys as $key) {
+            $path = 'data.attributes.' . $key;
+            $value = self::arrayGet($json, $path, '');
+            if (empty($value)) {
+                yield sprintf('Missing or empty attribute `%s`', $key);
+            }
+        }
+    }
+
+    private function createResource(array $json): StockImage
+    {
+        $resource = new StockImage();
+        $resource->setData(
+            array_merge(
+                self::getAttributeDefaults(),
+                self::getAttributeUpdates($json, ['title', 'description', 'author', 'license']),
+                self::getTags($json)
+            )
+        );
+        $resource->store();
+
+        return $resource;
+    }
+
+    private static function getAttributeDefaults(): iterable
+    {
+        return [
+            'height' => 0,
+            'mime_type' => '',
+            'size' => 0,
+            'width' => 0,
+            'tags' => '[]',
+        ];
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php
new file mode 100644
index 0000000000000000000000000000000000000000..e0b5468e3aa7d3064537ca446f04893197708151
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use StockImage;
+use User;
+
+/**
+ * Deletes one stock image.
+ */
+class StockImagesDelete extends JsonApiController
+{
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $resource = StockImage::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canDeleteStockImage($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource->delete();
+
+        return $this->getCodeResponse(204);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesIndex.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesIndex.php
new file mode 100644
index 0000000000000000000000000000000000000000..8bd3516f313ae7a7e3f1f45d048417cbedb0c697
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesIndex.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays all stock images.
+ */
+class StockImagesIndex extends JsonApiController
+{
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response): Response
+    {
+        if (!Authority::canIndexStockImages($this->getUser($request))) {
+            throw new AuthorizationFailedException();
+        }
+
+        list($offset, $limit) = $this->getOffsetAndLimit();
+        $total = \StockImage::countBySQL('1');
+        $stockImages = \StockImage::findBySQL("1 ORDER BY title ASC LIMIT {$offset}, {$limit}");
+
+        return $this->getPaginatedContentResponse($stockImages, $total);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesShow.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesShow.php
new file mode 100644
index 0000000000000000000000000000000000000000..52e79e16e7b37c311d2b875aa70983a66ff63bf2
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesShow.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays one stock image.
+ */
+class StockImagesShow extends JsonApiController
+{
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $resource = \StockImage::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowStockImage($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        return $this->getContentResponse($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php
new file mode 100644
index 0000000000000000000000000000000000000000..cef9db5cac7eaa55206e12f7f57c8875f773ff85
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use JsonApi\Schemas\StockImage as ResourceSchema;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use StockImage;
+use User;
+
+/**
+ * Updates a stock image.
+ */
+class StockImagesUpdate extends JsonApiController
+{
+    use ValidationTrait;
+    use ValidationHelpers;
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $resource = StockImage::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        $json = $this->validate($request, $resource);
+        $user = $this->getUser($request);
+        if (!Authority::canUpdateStockImage($user, $resource)) {
+            throw new AuthorizationFailedException();
+        }
+        $resource = $this->updateResource($resource, $json);
+
+        return $this->getContentResponse($resource);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    protected function validateResourceDocument($json, $data)
+    {
+        if (!self::arrayHas($json, 'data')) {
+            return 'Missing `data` member at document´s top level.';
+        }
+
+        if (ResourceSchema::TYPE !== self::arrayGet($json, 'data.type')) {
+            return 'Wrong `type` member of document´s `data`.';
+        }
+
+        if (!self::arrayHas($json, 'data.id')) {
+            return 'Document must have an `id`.';
+        }
+
+        $errors = iterator_to_array(self::nonEmptyAttributes($json, ['title', 'description', 'author', 'license']));
+        if (!empty($errors)) {
+            return current($errors);
+        }
+
+        // optional attribute `tags` has to be an array
+        if (self::arrayHas($json, 'data.attributes.tags')) {
+            $tags = self::arrayGet($json, 'data.attributes.tags', []);
+            if (!is_array($tags) || !array_is_list($tags)) {
+                return 'Attribute `tags` has to be a list of strings.';
+            }
+        }
+    }
+
+    protected static function nonEmptyAttributes($json, $keys)
+    {
+        foreach ($keys as $key) {
+            $path = 'data.attributes.' . $key;
+            if (self::arrayHas($json, $path)) {
+                $value = self::arrayGet($json, $path);
+                if (empty($value)) {
+                    yield sprintf('Attribute `%s` must not be empty', $key);
+                }
+            }
+        }
+    }
+
+    private function updateResource(StockImage $resource, array $json): void
+    {
+        $updates = array_merge(
+            self::getAttributeUpdates($json, ['title', 'description', 'author', 'license']),
+            self::getTags($json)
+        );
+        $resource->setData($updates);
+        $resource->store();
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php
new file mode 100644
index 0000000000000000000000000000000000000000..b05b370cab7958b459568f030ec4355dd284aa09
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\NonJsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Slim\Psr7\UploadedFile;
+use Studip\StockImages\Scaler;
+use Studip\StockImages\PaletteCreator;
+
+class StockImagesUpload extends NonJsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        $resource = \StockImage::find($args['id']);
+        if (!$resource) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canUploadStockImage($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+        $this->handleUpload($request, $resource);
+        $this->processStockImage($resource);
+
+        return $this->redirectToStockImage($response, $resource);
+    }
+
+    private function handleUpload(Request $request, \StockImage $resource): void
+    {
+        $uploadedFile = $this->getUploadedFile($request);
+        if (UPLOAD_ERR_OK !== $uploadedFile->getError()) {
+            $error = $this->getErrorString($uploadedFile->getError());
+            throw new BadRequestException($error);
+        }
+
+        $error = self::validate($uploadedFile);
+        if (!empty($error)) {
+            throw new BadRequestException($error);
+        }
+
+        $resource->mime_type = $uploadedFile->getClientMediaType();
+        $resource->size = $uploadedFile->getSize();
+        $uploadedFile->moveTo($resource->getPath());
+
+        $imageSize = getimagesize($resource->getPath());
+        $resource->width = $imageSize[0];
+        $resource->height = $imageSize[1];
+
+        $resource->store();
+    }
+
+    private function getUploadedFile(Request $request): UploadedFile
+    {
+        $files = iterator_to_array($this->getUploadedFiles($request));
+
+        if (0 === count($files)) {
+            throw new BadRequestException('File upload required.');
+        }
+
+        if (count($files) > 1) {
+            throw new BadRequestException('Multiple file upload not possible.');
+        }
+
+        $uploadedFile = reset($files);
+        if (UPLOAD_ERR_OK !== $uploadedFile->getError()) {
+            throw new BadRequestException('Upload error.');
+        }
+
+        return $uploadedFile;
+    }
+
+    /**
+     * @return iterable<UploadedFile> a list of uploaded files
+     */
+    private function getUploadedFiles(Request $request): iterable
+    {
+        foreach ($request->getUploadedFiles() as $item) {
+            if (!is_array($item)) {
+                yield $item;
+                continue;
+            }
+            foreach ($item as $file) {
+                yield $file;
+            }
+        }
+    }
+
+    private function getErrorString(int $errNo): string
+    {
+        $errors = [
+            UPLOAD_ERR_OK => 'There is no error, the file uploaded with success',
+            UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
+            UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',
+            UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded',
+            UPLOAD_ERR_NO_FILE => 'No file was uploaded',
+            UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
+            UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.',
+            UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload.',
+        ];
+
+        return $errors[$errNo] ?? '';
+    }
+
+    /**
+     * @return string|null null, if the file is valid, otherwise a string containing the error
+     */
+    private function validate(UploadedFile $file)
+    {
+        $mimeType = $file->getClientMediaType();
+        if (!in_array($mimeType, ['image/gif', 'image/jpeg', 'image/png', 'image/webp'])) {
+            return 'Unsupported media type.';
+        }
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.Superglobals)
+     */
+    private function redirectToStockImage(Response $response, \StockImage $stockImage): Response
+    {
+        $pathinfo = $this->getSchema($stockImage)
+            ->getSelfLink($stockImage)
+            ->getStringRepresentation($this->container->get('json-api-integration-urlPrefix'));
+        $old = \URLHelper::setBaseURL($GLOBALS['ABSOLUTE_URI_STUDIP']);
+        $url = \URLHelper::getURL($pathinfo, [], true);
+        \URLHelper::setBaseURL($old);
+
+        return $response->withHeader('Location', $url)->withStatus(201);
+    }
+
+    private function processStockImage(\StockImage $resource): void
+    {
+        $scaler = new Scaler();
+        $scaler($resource);
+        $paletteCreator = new PaletteCreator();
+        $paletteCreator($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StockImages/ValidationHelpers.php b/lib/classes/JsonApi/Routes/StockImages/ValidationHelpers.php
new file mode 100644
index 0000000000000000000000000000000000000000..b0cf1c69bec0cc809a00af238267a8a378dc86f9
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/ValidationHelpers.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+trait ValidationHelpers
+{
+    protected static function getAttributeUpdates($json, iterable $keys): iterable
+    {
+        return array_reduce(
+            $keys,
+            function ($memo, $key) use ($json) {
+                $path = 'data.attributes.' . $key;
+                if (self::arrayHas($json, $path)) {
+                    $memo[$key] = self::arrayGet($json, $path);
+                }
+                return $memo;
+            },
+            []
+        );
+    }
+
+    protected static function getTags($json): iterable
+    {
+        return ['tags' => json_encode(self::arrayGet($json, 'data.attributes.tags', []))];
+    }
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index e5a2f678f5f6bf99752e3dd99c1a3c2e0890c87c..dd74bc9bc2a20957c438c5bb3d7832a3d7a5e208 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -40,6 +40,7 @@ class SchemaMap
             \SemType::class => Schemas\SemType::class,
             \SeminarCycleDate::class => Schemas\SeminarCycleDate::class,
             \Statusgruppen::class => Schemas\StatusGroup::class,
+            \StockImage::class => Schemas\StockImage::class,
             \JsonApi\Models\Studip::class => Schemas\Studip::class,
             \JsonApi\Models\StudipProperty::class => Schemas\StudipProperty::class,
             \StudipComment::class => Schemas\StudipComment::class,
diff --git a/lib/classes/JsonApi/Schemas/StockImage.php b/lib/classes/JsonApi/Schemas/StockImage.php
new file mode 100644
index 0000000000000000000000000000000000000000..e28582c5c99a5554f283a0d0d0b4452f187380c8
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/StockImage.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+use Neomerx\JsonApi\Contracts\Schema\LinkInterface;
+
+class StockImage extends SchemaProvider
+{
+    public const TYPE = 'stock-images';
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getId($resource): ?string
+    {
+        return $resource->getId();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'title' => $resource['title'],
+            'description' => $resource['description'],
+            'author' => $resource['author'],
+            'license' => $resource['license'],
+            'mime-type' => $resource['mime_type'],
+
+            'download-urls' => $resource->getDownloadURLs(),
+
+            'size' => (int) $resource['size'],
+            'width' => (int) $resource['width'],
+            'height' => (int) $resource['height'],
+            'palette' => empty($resource['palette']) ? null : json_decode($resource['palette']),
+            'tags' => empty($resource['tags']) ? [] : json_decode($resource['tags']),
+
+            'mkdate' => date('c', $resource['mkdate']),
+            'chdate' => date('c', $resource['chdate']),
+        ];
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        return [];
+    }
+}
diff --git a/lib/classes/StockImages/PaletteCreator.php b/lib/classes/StockImages/PaletteCreator.php
new file mode 100644
index 0000000000000000000000000000000000000000..73386ef5607a1d872b4c81af52d4ed7a8b5073a4
--- /dev/null
+++ b/lib/classes/StockImages/PaletteCreator.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Studip\StockImages;
+
+use ColorThief\ColorThief;
+
+final class PaletteCreator
+{
+    /**
+     * @param \StockImage $stockImage
+     */
+    public function __invoke(\StockImage $stockImage): void
+    {
+        $sourceImage = $stockImage->getPath(\StockImage::SIZE_SMALL);
+        $palette = ColorThief::getPalette($sourceImage, 3);
+        $stockImage->palette = json_encode($palette);
+        $stockImage->store();
+    }
+}
diff --git a/lib/classes/StockImages/Scaler.php b/lib/classes/StockImages/Scaler.php
new file mode 100644
index 0000000000000000000000000000000000000000..32f6c7aa92d98174fecabe8754723267ae9f0369
--- /dev/null
+++ b/lib/classes/StockImages/Scaler.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Studip\StockImages;
+
+final class Scaler
+{
+    /**
+     * @param \StockImage $stockImage
+     */
+    public function __invoke(\StockImage $stockImage): void
+    {
+        foreach (\StockImage::sizes() as $name => $width) {
+            if ($name !== \StockImage::SIZE_ORIGINAL) {
+                $this->scaleToWidth($stockImage, $name, $width);
+            }
+        }
+    }
+
+    private function scaleToWidth(\StockImage $stockImage, string $sizeName, int $targetWidth): bool
+    {
+        $image = $this->createImage($stockImage);
+        $width = imagesx($image);
+        if ($width < $targetWidth) {
+            return false;
+        }
+
+        $scaledImage = imagescale($image, $targetWidth);
+        imagedestroy($image);
+
+        return $this->storeImage($stockImage, $scaledImage, $sizeName);
+    }
+
+    /**
+     * @return resource the \GDImage created from the original image file
+     */
+    private function createImage(\StockImage $stockImage)
+    {
+        $type = $stockImage->mime_type;
+        $lookup = [
+            'image/gif' => 'imagecreatefromgif',
+            'image/jpeg' => 'imagecreatefromjpeg',
+            'image/png' => 'imagecreatefrompng',
+            'image/webp' => 'imagecreatefromwebp',
+        ];
+        if (!isset($lookup[$type])) {
+            throw new \RuntimeException(_('Unsupported image type.'));
+        }
+
+        return $lookup[$type]($stockImage->getPath());
+    }
+
+    /**
+     * @param resource $image the scaled image
+     */
+    private function storeImage(\StockImage $stockImage, $image, string $sizeName): bool
+    {
+        $type = $stockImage->mime_type;
+        $lookup = [
+            'image/gif' => 'imagegif',
+            'image/jpeg' => 'imagejpeg',
+            'image/png' => 'imagepng',
+            'image/webp' => 'imagewebp',
+        ];
+        if (!isset($lookup[$type])) {
+            throw new \RuntimeException(_('Unsupported image type.'));
+        }
+
+        return $lookup[$type]($image, $stockImage->getPath($sizeName));
+    }
+}
diff --git a/lib/models/Courseware/BlockTypes/Headline.json b/lib/models/Courseware/BlockTypes/Headline.json
index 1e759e478fc0a95aed7e123231746bf9bfe0b079..5eab70554a64be05f85944e91155563e2b522cb8 100644
--- a/lib/models/Courseware/BlockTypes/Headline.json
+++ b/lib/models/Courseware/BlockTypes/Headline.json
@@ -26,6 +26,9 @@
         "background_image_id": {
             "type": "string"
         },
+        "background_image_type": {
+            "type": "string"
+        },
         "background_type": {
             "type": "string"
         },
diff --git a/lib/models/Courseware/StructuralElement.php b/lib/models/Courseware/StructuralElement.php
index aee01a2c3ca4e4e64a1641a3f790cb1cb6d8dd5a..d3c77fafe1e8709c1a5ef6ebfa19a8c610491c41 100644
--- a/lib/models/Courseware/StructuralElement.php
+++ b/lib/models/Courseware/StructuralElement.php
@@ -26,6 +26,7 @@ use User;
  * @property int                            $position           database column
  * @property string                         $title              database column
  * @property string                         $image_id           database column
+ * @property string                         $image_type         database column
  * @property string                         $purpose            database column
  * @property \JSONArrayObject               $payload            database column
  * @property int                            $public             database column
@@ -45,7 +46,7 @@ use User;
  * @property \User                          $owner              belongs_to User
  * @property \User                          $editor             belongs_to User
  * @property ?\User                         $edit_blocker       belongs_to User
- * @property ?\FileRef                      $image              has_one FileRef
+ * @property \FileRef|\StockImage|null      $image              has_one FileRef or StockImage
  * @property ?\Courseware\Task              $task               has_one Courseware\Task
  * @property \SimpleORMapCollection         $comments           has_many Courseware\StructuralElementComment
  * @property \SimpleORMapCollection         $feedback           has_many Courseware\StructuralElementFeedback
@@ -119,13 +120,6 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject
             'foreign_key' => 'edit_blocker_id',
         ];
 
-        $config['has_one']['image'] = [
-            'class_name' => \FileRef::class,
-            'foreign_key' => 'image_id',
-            'on_delete' => 'delete',
-            'on_store' => 'store',
-        ];
-
         $config['has_many']['comments'] = [
             'class_name' => StructuralElementComment::class,
             'assoc_foreign_key' => 'structural_element_id',
@@ -142,9 +136,58 @@ class StructuralElement extends \SimpleORMap implements \PrivacyObject
             'order_by' => 'ORDER BY chdate',
         ];
 
+        $config['additional_fields']['image'] = [
+            'get' => 'getImage',
+            'set' => 'setImage'
+        ];
+
+        $config['registered_callbacks']['before_delete'][] = 'cbBeforeDelete';
+
         parent::configure($config);
     }
 
+    public function cbBeforeDelete()
+    {
+        $image = $this->getImage();
+        if (is_a($image, \FileRef::class)) {
+            $image->delete();
+        }
+    }
+
+    /**
+     * @return null|\FileRef|\StockImage
+     */
+    public function getImage()
+    {
+        if (!$this->image_id) {
+            return null;
+        }
+
+        if (!in_array($this->image_type, [\FileRef::class, \StockImage::class])) {
+            return null;
+        }
+
+        return $this->image_type::find($this->image_id);
+    }
+
+    /**
+     * @param $image null|\FileRef|\StockImage
+     */
+    public function setImage($image): void
+    {
+        if (is_null($image)) {
+            $this->image_id = null;
+        } elseif (is_a($image, \FileRef)) {
+            $this->image_id = $image->getId();
+            $this->image_type = \FileRef::class;
+        } elseif (is_a($image, \StockImage)) {
+            $this->image_id = $image->getId();
+            $this->image_type = \StockImage::class;
+        } else {
+            throw \BadMethodCallException('Invalid argument to method ' . __METHOD__);
+        }
+    }
+
     /**
      * Returns the root element of a courseware instance for a user.
      *
@@ -762,11 +805,20 @@ SQL;
     /**
      * Returns the URL of the image associated to this structural element.
      *
-     * @return string the image URL, if it exists; an empty string otherwise
+     * @return string|null the image URL, if it exists
      */
     public function getImageUrl()
     {
-        return $this->image ? $this->image->getDownloadURL() : null;
+        $image = $this->getImage();
+        if ($image) {
+            if (is_a($image, \FileRef::class)) {
+                return $image->getDownloadURL();
+            } elseif (is_a($image, \StockImage::class)) {
+                return $image->getDownloadURL(\StockImage::SIZE_SMALL);
+            }
+        }
+
+        return null;
     }
 
     public static function getClipboardBackup(): string
@@ -800,8 +852,9 @@ SQL;
 
         $element->store();
 
-        $file_ref_id = $this->copyImage($user, $element);
-        $element->image_id = $file_ref_id;
+        $image_id = $this->copyImage($user, $element);
+        $element->image_id = $image_id;
+        $element->image_type = $this->image_type;
         $element->store();
 
         $this->copyContainers($user, $element);
@@ -830,7 +883,7 @@ SQL;
         }
         static $mapping = [];
 
-        $file_ref_id = $this->copyImage($user, $parent);
+        $image_id = $this->copyImage($user, $parent);
 
         $element = self::build([
             'parent_id' => $parent->id,
@@ -843,7 +896,8 @@ SQL;
             'purpose' => empty($purpose) ? $this->purpose : $purpose,
             'position' => $parent->countChildren(),
             'payload' => $this->payload,
-            'image_id' => $file_ref_id,
+            'image_id' => $image_id,
+            'image_type' => $this->image_type,
             'read_approval' => $parent->read_approval,
             'write_approval' => $parent->write_approval
         ]);
@@ -876,19 +930,27 @@ SQL;
 
     private function copyImage(User $user, StructuralElement $parent) : ?string
     {
-        $file_ref_id = null;
-
-        /** @var ?\FileRef $original_file_ref */
-        $original_file_ref = \FileRef::find($this->image_id);
-        if ($original_file_ref) {
-            $instance = new Instance($this->getCourseware($parent->range_id, $parent->range_type));
-            $folder = \Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($instance);
-            /** @var \FileRef $file_ref */
-            $file_ref = \FileManager::copyFile($original_file_ref->getFileType(), $folder, $user);
-            $file_ref_id = $file_ref->id;
+        if ($this->image_type === \StockImage::class) {
+            return $this->image_id;
+        }
+
+        if ($this->image_type === \FileRef::class) {
+            $file_ref_id = null;
+
+            /** @var ?\FileRef $original_file_ref */
+            $original_file_ref = \FileRef::find($this->image_id);
+            if ($original_file_ref) {
+                $instance = new Instance($this->getCourseware($parent->range_id, $parent->range_type));
+                $folder = \Courseware\Filesystem\PublicFolder::findOrCreateTopFolder($instance);
+                /** @var \FileRef $file_ref */
+                $file_ref = \FileManager::copyFile($original_file_ref->getFileType(), $folder, $user);
+                $file_ref_id = $file_ref->id;
+            }
+
+            return $file_ref_id;
         }
 
-        return $file_ref_id;
+        return null;
     }
 
     public function merge(User $user, StructuralElement $target): StructuralElement
@@ -896,6 +958,7 @@ SQL;
         // merge with target
         if (!$target->image_id) {
             $target->image_id = $this->copyImage($user, $target);
+            $target->image_type = $this->image_type;
         }
 
         if ($target->title === 'neue Seite' || $target->title === 'New page') {
diff --git a/lib/models/StockImage.php b/lib/models/StockImage.php
new file mode 100644
index 0000000000000000000000000000000000000000..54d09921d0b8ef6b9206409fe6a714f10f448a39
--- /dev/null
+++ b/lib/models/StockImage.php
@@ -0,0 +1,118 @@
+<?php
+
+/**
+ * @property string $id          database column
+ * @property string $title       database column
+ * @property string $description database column
+ * @property string $license     database column
+ * @property string $author      database column
+ * @property string $mime_type   database column
+ * @property int    $size        database column
+ * @property int    $width       database column
+ * @property int    $height      database column
+ * @property string $palette     database column
+ * @property string $tags     database column
+ * @property int    $mkdate      database column
+ * @property int    $chdate      database column
+ */
+class StockImage extends \SimpleORMap
+{
+    public const SIZE_ORIGINAL = 'original';
+    public const SIZE_LARGE = 'large';
+    public const SIZE_MEDIUM = 'medium';
+    public const SIZE_SMALL = 'small';
+
+    public static function sizes()
+    {
+        return [
+            self::SIZE_ORIGINAL => -1,
+            self::SIZE_LARGE => 2400,
+            self::SIZE_MEDIUM => 1920,
+            self::SIZE_SMALL => 640,
+        ];
+    }
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'stock_images';
+
+        $config['registered_callbacks']['after_delete'][] = function ($resource) {
+            if ($resource->hasFile()) {
+                foreach (array_keys(self::sizes()) as $sizeName) {
+                    $path = $resource->getPath($sizeName);
+                    if (file_exists($path)) {
+                        unlink($path);
+                    }
+                }
+            }
+        };
+
+        parent::configure($config);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.Superglobals)
+     */
+    public function getPath(string $size = self::SIZE_ORIGINAL): string
+    {
+        return sprintf(
+            '%s/public/pictures/stock-images/%s',
+            $GLOBALS['STUDIP_BASE_PATH'],
+            $this->getFilename($size)
+        );
+    }
+
+    public function getFilename(string $size = self::SIZE_ORIGINAL): string
+    {
+        return sprintf(
+            '%d-%s.%s',
+            $this->id,
+            $size,
+            substr($this->mime_type, 6)
+        );
+    }
+
+    /**
+     * return string|null  either a string containing the public URL to the file
+     *                     or null if there is still no such file
+     *
+     * @SuppressWarnings(PHPMD.Superglobals)
+     */
+    public function getDownloadURL(string $size = self::SIZE_ORIGINAL)
+    {
+        if (!$this->hasFile()) {
+            return null;
+        }
+        $sizes = self::sizes();
+        if (!(isset($sizes[$size]) && $sizes[$size] <= $this->width)) {
+            return null;
+        }
+
+        return sprintf(
+            '%spictures/stock-images/%s',
+            $GLOBALS['ABSOLUTE_URI_STUDIP'],
+            $this->getFilename($size)
+        );
+    }
+
+    /**
+     * @return iterable<string,string> an associative array of sizes to URLs
+     */
+    public function getDownloadURLs(): iterable
+    {
+        return array_filter(
+            array_reduce(
+                array_keys(self::sizes()),
+                function ($urls, $size) {
+                    return array_merge($urls, [$size => $this->getDownloadURL($size)]);
+                },
+                []
+            )
+        );
+    }
+
+    public function hasFile(): bool
+    {
+        return !empty($this->mime_type);
+    }
+}
diff --git a/lib/navigation/ContentsNavigation.php b/lib/navigation/ContentsNavigation.php
index 119de6a73d0631af9943d8b17efa88d09d7680a6..c206ef5daf658b2c145fe7aef1180f07ad0a6b07 100644
--- a/lib/navigation/ContentsNavigation.php
+++ b/lib/navigation/ContentsNavigation.php
@@ -139,6 +139,13 @@ class ContentsNavigation extends Navigation
             $this->addSubNavigation('my_elearning', $elearning);
         }
 
+        if ($GLOBALS['perm']->have_perm('admin')) {
+            $pool = new Navigation(_('Bilder-Pool'), 'dispatch.php/stock_images', []);
+            $pool->setImage(Icon::create('picture'));
+            $pool->setDescription(_('Verwalten Sie den Pool frei verfügbarer Bilder.'));
+            $this->addSubNavigation('stock_images', $pool);
+        }
+
         if (!$GLOBALS['perm']->have_perm('root') && $GLOBALS['user']->getAuthenticatedUser()->hasRole('Hilfe-Administrator(in)')) {
             $help = new Navigation(_('Hilfe'), 'dispatch.php/help_content/admin_overview');
             $help->setImage(Icon::create('question-circle'));
diff --git a/package-lock.json b/package-lock.json
index 0b087b56017446c9b3dd6b2b60a9ed08039e56f7..9a91eebb352185e8021386ca761986d9b2114e27 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -48,6 +48,7 @@
                 "@fullcalendar/resource-timegrid": "^4.3.0",
                 "@fullcalendar/resource-timeline": "^4.3.0",
                 "@fullcalendar/timegrid": "^4.3.0",
+                "@johmun/vue-tags-input": "^2.1.0",
                 "@playwright/test": "^1.33.0",
                 "@popperjs/core": "^2.11.2",
                 "@types/jquery": "^3.5.16",
@@ -62,6 +63,7 @@
                 "chart.js": "^2.9.4",
                 "chartist": "0.11.4",
                 "ckeditor5-math": "34.1.1",
+                "colorpare": "^2.2.0",
                 "cropperjs": "1.5.9",
                 "css-loader": "^5.0.1",
                 "css-minimizer-webpack-plugin": "^1.1.5",
@@ -135,6 +137,15 @@
                 "node": ">=16"
             }
         },
+        "node_modules/@aashutoshrathi/word-wrap": {
+            "version": "1.2.6",
+            "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
+            "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
         "node_modules/@ampproject/remapping": {
             "version": "2.2.1",
             "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
@@ -173,35 +184,35 @@
             }
         },
         "node_modules/@babel/compat-data": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.5.tgz",
-            "integrity": "sha512-4Jc/YuIaYqKnDDz892kPIledykKg12Aw1PYX5i/TY28anJtacvM1Rrr8wbieB9GfEJwlzqT0hUEao0CxEebiDA==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.6.tgz",
+            "integrity": "sha512-29tfsWTq2Ftu7MXmimyC0C5FDZv5DYxOZkh3XD3+QW4V/BYuv/LyEsjj3c0hqedEaDt6DBfDvexMKU8YevdqFg==",
             "dev": true,
             "engines": {
                 "node": ">=6.9.0"
             }
         },
         "node_modules/@babel/core": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.5.tgz",
-            "integrity": "sha512-SBuTAjg91A3eKOvD+bPEz3LlhHZRNu1nFOVts9lzDJTXshHTjII0BAtDS3Y2DAkdZdDKWVZGVwkDfc4Clxn1dg==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.6.tgz",
+            "integrity": "sha512-HPIyDa6n+HKw5dEuway3vVAhBboYCtREBMp+IWeseZy6TFtzn6MHkCH2KKYUOC/vKKwgSMHQW4htBOrmuRPXfw==",
             "dev": true,
             "dependencies": {
                 "@ampproject/remapping": "^2.2.0",
                 "@babel/code-frame": "^7.22.5",
                 "@babel/generator": "^7.22.5",
-                "@babel/helper-compilation-targets": "^7.22.5",
+                "@babel/helper-compilation-targets": "^7.22.6",
                 "@babel/helper-module-transforms": "^7.22.5",
-                "@babel/helpers": "^7.22.5",
-                "@babel/parser": "^7.22.5",
+                "@babel/helpers": "^7.22.6",
+                "@babel/parser": "^7.22.6",
                 "@babel/template": "^7.22.5",
-                "@babel/traverse": "^7.22.5",
+                "@babel/traverse": "^7.22.6",
                 "@babel/types": "^7.22.5",
+                "@nicolo-ribaudo/semver-v6": "^6.3.3",
                 "convert-source-map": "^1.7.0",
                 "debug": "^4.1.0",
                 "gensync": "^1.0.0-beta.2",
-                "json5": "^2.2.2",
-                "semver": "^6.3.0"
+                "json5": "^2.2.2"
             },
             "engines": {
                 "node": ">=6.9.0"
@@ -212,14 +223,14 @@
             }
         },
         "node_modules/@babel/eslint-parser": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.22.5.tgz",
-            "integrity": "sha512-C69RWYNYtrgIRE5CmTd77ZiLDXqgBipahJc/jHP3sLcAGj6AJzxNIuKNpVnICqbyK7X3pFUfEvL++rvtbQpZkQ==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.22.6.tgz",
+            "integrity": "sha512-KAom7E7d6bAh5/PflF3luynWlDLOIqfX+ZJcL0LRs6/6rtXJmJxPiWuIGfxNPtcWdtQ5lSSJbKbQlz/c/R60Ng==",
             "dev": true,
             "dependencies": {
                 "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
-                "eslint-visitor-keys": "^2.1.0",
-                "semver": "^6.3.0"
+                "@nicolo-ribaudo/semver-v6": "^6.3.3",
+                "eslint-visitor-keys": "^2.1.0"
             },
             "engines": {
                 "node": "^10.13.0 || ^12.13.0 || >=14.0.0"
@@ -269,16 +280,16 @@
             }
         },
         "node_modules/@babel/helper-compilation-targets": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.5.tgz",
-            "integrity": "sha512-Ji+ywpHeuqxB8WDxraCiqR0xfhYjiDE/e6k7FuIaANnoOFxAHskHChz4vA1mJC9Lbm01s1PVAGhQY4FUKSkGZw==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.6.tgz",
+            "integrity": "sha512-534sYEqWD9VfUm3IPn2SLcH4Q3P86XL+QvqdC7ZsFrzyyPF3T4XGiVghF6PTYNdWg6pXuoqXxNQAhbYeEInTzA==",
             "dev": true,
             "dependencies": {
-                "@babel/compat-data": "^7.22.5",
+                "@babel/compat-data": "^7.22.6",
                 "@babel/helper-validator-option": "^7.22.5",
-                "browserslist": "^4.21.3",
-                "lru-cache": "^5.1.1",
-                "semver": "^6.3.0"
+                "@nicolo-ribaudo/semver-v6": "^6.3.3",
+                "browserslist": "^4.21.9",
+                "lru-cache": "^5.1.1"
             },
             "engines": {
                 "node": ">=6.9.0"
@@ -288,9 +299,9 @@
             }
         },
         "node_modules/@babel/helper-create-class-features-plugin": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.5.tgz",
-            "integrity": "sha512-xkb58MyOYIslxu3gKmVXmjTtUPvBU4odYzbiIQbWwLKIHCsx6UGZGX6F1IznMFVnDdirseUZopzN+ZRt8Xb33Q==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.6.tgz",
+            "integrity": "sha512-iwdzgtSiBxF6ni6mzVnZCF3xt5qE6cEA0J7nFt8QOAWZ0zjCFceEgpn3vtb2V7WFR6QzP2jmIFOHMTRo7eNJjQ==",
             "dev": true,
             "dependencies": {
                 "@babel/helper-annotate-as-pure": "^7.22.5",
@@ -300,8 +311,8 @@
                 "@babel/helper-optimise-call-expression": "^7.22.5",
                 "@babel/helper-replace-supers": "^7.22.5",
                 "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
-                "@babel/helper-split-export-declaration": "^7.22.5",
-                "semver": "^6.3.0"
+                "@babel/helper-split-export-declaration": "^7.22.6",
+                "@nicolo-ribaudo/semver-v6": "^6.3.3"
             },
             "engines": {
                 "node": ">=6.9.0"
@@ -311,14 +322,14 @@
             }
         },
         "node_modules/@babel/helper-create-regexp-features-plugin": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.5.tgz",
-            "integrity": "sha512-1VpEFOIbMRaXyDeUwUfmTIxExLwQ+zkW+Bh5zXpApA3oQedBx9v/updixWxnx/bZpKw7u8VxWjb/qWpIcmPq8A==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.6.tgz",
+            "integrity": "sha512-nBookhLKxAWo/TUCmhnaEJyLz2dekjQvv5SRpE9epWQBcpedWLKt8aZdsuT9XV5ovzR3fENLjRXVT0GsSlGGhA==",
             "dev": true,
             "dependencies": {
                 "@babel/helper-annotate-as-pure": "^7.22.5",
-                "regexpu-core": "^5.3.1",
-                "semver": "^6.3.0"
+                "@nicolo-ribaudo/semver-v6": "^6.3.3",
+                "regexpu-core": "^5.3.1"
             },
             "engines": {
                 "node": ">=6.9.0"
@@ -344,6 +355,15 @@
                 "@babel/core": "^7.4.0-0"
             }
         },
+        "node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/@babel/helper-environment-visitor": {
             "version": "7.22.5",
             "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz",
@@ -502,9 +522,9 @@
             }
         },
         "node_modules/@babel/helper-split-export-declaration": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.5.tgz",
-            "integrity": "sha512-thqK5QFghPKWLhAV321lxF95yCg2K3Ob5yw+M3VHWfdia0IkPXUtoLH8x/6Fh486QUvzhb8YOWHChTVen2/PoQ==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
+            "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
             "dev": true,
             "dependencies": {
                 "@babel/types": "^7.22.5"
@@ -556,13 +576,13 @@
             }
         },
         "node_modules/@babel/helpers": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.5.tgz",
-            "integrity": "sha512-pSXRmfE1vzcUIDFQcSGA5Mr+GxBV9oiRKDuDxXvWQQBCh8HoIjs/2DlDB7H8smac1IVrB9/xdXj2N3Wol9Cr+Q==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.6.tgz",
+            "integrity": "sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==",
             "dev": true,
             "dependencies": {
                 "@babel/template": "^7.22.5",
-                "@babel/traverse": "^7.22.5",
+                "@babel/traverse": "^7.22.6",
                 "@babel/types": "^7.22.5"
             },
             "engines": {
@@ -584,9 +604,9 @@
             }
         },
         "node_modules/@babel/parser": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.5.tgz",
-            "integrity": "sha512-DFZMC9LJUG9PLOclRC32G63UXwzqS2koQC8dkx+PLdmt1xSePYpbT/NbsrJy8Q/muXz7o/h/d4A7Fuyixm559Q==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.6.tgz",
+            "integrity": "sha512-EIQu22vNkceq3LbjAq7knDf/UmtI2qbcNI8GRBlijez6TpQLvSodJPYfydQmNA5buwkxxxa/PVI44jjYZ+/cLw==",
             "dev": true,
             "bin": {
                 "parser": "bin/babel-parser.js"
@@ -1098,19 +1118,19 @@
             }
         },
         "node_modules/@babel/plugin-transform-classes": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.5.tgz",
-            "integrity": "sha512-2edQhLfibpWpsVBx2n/GKOz6JdGQvLruZQfGr9l1qes2KQaWswjBzhQF7UDUZMNaMMQeYnQzxwOMPsbYF7wqPQ==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.6.tgz",
+            "integrity": "sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==",
             "dev": true,
             "dependencies": {
                 "@babel/helper-annotate-as-pure": "^7.22.5",
-                "@babel/helper-compilation-targets": "^7.22.5",
+                "@babel/helper-compilation-targets": "^7.22.6",
                 "@babel/helper-environment-visitor": "^7.22.5",
                 "@babel/helper-function-name": "^7.22.5",
                 "@babel/helper-optimise-call-expression": "^7.22.5",
                 "@babel/helper-plugin-utils": "^7.22.5",
                 "@babel/helper-replace-supers": "^7.22.5",
-                "@babel/helper-split-export-declaration": "^7.22.5",
+                "@babel/helper-split-export-declaration": "^7.22.6",
                 "globals": "^11.1.0"
             },
             "engines": {
@@ -1506,9 +1526,9 @@
             }
         },
         "node_modules/@babel/plugin-transform-optional-chaining": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.5.tgz",
-            "integrity": "sha512-AconbMKOMkyG+xCng2JogMCDcqW8wedQAqpVIL4cOSescZ7+iW8utC6YDZLMCSUIReEA733gzRSaOSXMAt/4WQ==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.6.tgz",
+            "integrity": "sha512-Vd5HiWml0mDVtcLHIoEU5sw6HOUW/Zk0acLs/SAeuLzkGNOPc9DB4nkUajemhCmTIz3eiaKREZn2hQQqF79YTg==",
             "dev": true,
             "dependencies": {
                 "@babel/helper-plugin-utils": "^7.22.5",
@@ -1618,17 +1638,17 @@
             }
         },
         "node_modules/@babel/plugin-transform-runtime": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.5.tgz",
-            "integrity": "sha512-bg4Wxd1FWeFx3daHFTWk1pkSWK/AyQuiyAoeZAOkAOUBjnZPH6KT7eMxouV47tQ6hl6ax2zyAWBdWZXbrvXlaw==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.6.tgz",
+            "integrity": "sha512-+AGkst7Kqq3QUflKGkhWWMRb9vaKamoreNmYc+sjsIpOp+TsyU0exhp3RlwjQa/HdlKkPt3AMDwfg8Hpt9Vwqg==",
             "dev": true,
             "dependencies": {
                 "@babel/helper-module-imports": "^7.22.5",
                 "@babel/helper-plugin-utils": "^7.22.5",
+                "@nicolo-ribaudo/semver-v6": "^6.3.3",
                 "babel-plugin-polyfill-corejs2": "^0.4.3",
                 "babel-plugin-polyfill-corejs3": "^0.8.1",
-                "babel-plugin-polyfill-regenerator": "^0.5.0",
-                "semver": "^6.3.0"
+                "babel-plugin-polyfill-regenerator": "^0.5.0"
             },
             "engines": {
                 "node": ">=6.9.0"
@@ -1777,13 +1797,13 @@
             }
         },
         "node_modules/@babel/preset-env": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.5.tgz",
-            "integrity": "sha512-fj06hw89dpiZzGZtxn+QybifF07nNiZjZ7sazs2aVDcysAZVGjW7+7iFYxg6GLNM47R/thYfLdrXc+2f11Vi9A==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.6.tgz",
+            "integrity": "sha512-IHr0AXHGk8oh8HYSs45Mxuv6iySUBwDTIzJSnXN7PURqHdxJVQlCoXmKJgyvSS9bcNf9NVRVE35z+LkCvGmi6w==",
             "dev": true,
             "dependencies": {
-                "@babel/compat-data": "^7.22.5",
-                "@babel/helper-compilation-targets": "^7.22.5",
+                "@babel/compat-data": "^7.22.6",
+                "@babel/helper-compilation-targets": "^7.22.6",
                 "@babel/helper-plugin-utils": "^7.22.5",
                 "@babel/helper-validator-option": "^7.22.5",
                 "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.5",
@@ -1814,7 +1834,7 @@
                 "@babel/plugin-transform-block-scoping": "^7.22.5",
                 "@babel/plugin-transform-class-properties": "^7.22.5",
                 "@babel/plugin-transform-class-static-block": "^7.22.5",
-                "@babel/plugin-transform-classes": "^7.22.5",
+                "@babel/plugin-transform-classes": "^7.22.6",
                 "@babel/plugin-transform-computed-properties": "^7.22.5",
                 "@babel/plugin-transform-destructuring": "^7.22.5",
                 "@babel/plugin-transform-dotall-regex": "^7.22.5",
@@ -1839,7 +1859,7 @@
                 "@babel/plugin-transform-object-rest-spread": "^7.22.5",
                 "@babel/plugin-transform-object-super": "^7.22.5",
                 "@babel/plugin-transform-optional-catch-binding": "^7.22.5",
-                "@babel/plugin-transform-optional-chaining": "^7.22.5",
+                "@babel/plugin-transform-optional-chaining": "^7.22.6",
                 "@babel/plugin-transform-parameters": "^7.22.5",
                 "@babel/plugin-transform-private-methods": "^7.22.5",
                 "@babel/plugin-transform-private-property-in-object": "^7.22.5",
@@ -1857,11 +1877,11 @@
                 "@babel/plugin-transform-unicode-sets-regex": "^7.22.5",
                 "@babel/preset-modules": "^0.1.5",
                 "@babel/types": "^7.22.5",
+                "@nicolo-ribaudo/semver-v6": "^6.3.3",
                 "babel-plugin-polyfill-corejs2": "^0.4.3",
                 "babel-plugin-polyfill-corejs3": "^0.8.1",
                 "babel-plugin-polyfill-regenerator": "^0.5.0",
-                "core-js-compat": "^3.30.2",
-                "semver": "^6.3.0"
+                "core-js-compat": "^3.31.0"
             },
             "engines": {
                 "node": ">=6.9.0"
@@ -1912,9 +1932,9 @@
             "dev": true
         },
         "node_modules/@babel/runtime": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz",
-            "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz",
+            "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==",
             "dev": true,
             "dependencies": {
                 "regenerator-runtime": "^0.13.11"
@@ -1938,9 +1958,9 @@
             }
         },
         "node_modules/@babel/traverse": {
-            "version": "7.22.5",
-            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.5.tgz",
-            "integrity": "sha512-7DuIjPgERaNo6r+PZwItpjCZEa5vyw4eJGufeLxrPdBXBoLcCJCIasvK6pK/9DVNrLZTLFhUGqaC6X/PA007TQ==",
+            "version": "7.22.6",
+            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.6.tgz",
+            "integrity": "sha512-53CijMvKlLIDlOTrdWiHileRddlIiwUIyCKqYa7lYnnPldXCG5dUSN38uT0cA6i7rHWNKJLH0VU/Kxdr1GzB3w==",
             "dev": true,
             "dependencies": {
                 "@babel/code-frame": "^7.22.5",
@@ -1948,8 +1968,8 @@
                 "@babel/helper-environment-visitor": "^7.22.5",
                 "@babel/helper-function-name": "^7.22.5",
                 "@babel/helper-hoist-variables": "^7.22.5",
-                "@babel/helper-split-export-declaration": "^7.22.5",
-                "@babel/parser": "^7.22.5",
+                "@babel/helper-split-export-declaration": "^7.22.6",
+                "@babel/parser": "^7.22.6",
                 "@babel/types": "^7.22.5",
                 "debug": "^4.1.0",
                 "globals": "^11.1.0"
@@ -2314,18 +2334,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/@ckeditor/ckeditor5-dev-utils/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/@ckeditor/ckeditor5-dev-utils/node_modules/postcss-loader": {
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-4.3.0.tgz",
@@ -2368,21 +2376,6 @@
                 "url": "https://opencollective.com/webpack"
             }
         },
-        "node_modules/@ckeditor/ckeditor5-dev-utils/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/@ckeditor/ckeditor5-dev-utils/node_modules/supports-color": {
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2395,12 +2388,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/@ckeditor/ckeditor5-dev-utils/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/@ckeditor/ckeditor5-dev-webpack-plugin": {
             "version": "30.5.0",
             "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-dev-webpack-plugin/-/ckeditor5-dev-webpack-plugin-30.5.0.tgz",
@@ -2474,33 +2461,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/@ckeditor/ckeditor5-dev-webpack-plugin/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@ckeditor/ckeditor5-dev-webpack-plugin/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/@ckeditor/ckeditor5-dev-webpack-plugin/node_modules/supports-color": {
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2513,12 +2473,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/@ckeditor/ckeditor5-dev-webpack-plugin/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/@ckeditor/ckeditor5-easy-image": {
             "version": "34.2.0",
             "resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-easy-image/-/ckeditor5-easy-image-34.2.0.tgz",
@@ -3902,6 +3856,18 @@
                 "node": ">=8"
             }
         },
+        "node_modules/@johmun/vue-tags-input": {
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/@johmun/vue-tags-input/-/vue-tags-input-2.1.0.tgz",
+            "integrity": "sha512-Fdwfss/TqCqMJbGAkmlzKbcG/ia1MstYjhqPBj+zG7h/166tIcE1TIftUxhT9LZ+RWjRSG0EFA1UyaHQSr3k3Q==",
+            "dev": true,
+            "dependencies": {
+                "vue": "^2.6.10"
+            },
+            "peerDependencies": {
+                "vue": "2.x"
+            }
+        },
         "node_modules/@jridgewell/gen-mapping": {
             "version": "0.3.3",
             "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
@@ -3935,9 +3901,9 @@
             }
         },
         "node_modules/@jridgewell/source-map": {
-            "version": "0.3.3",
-            "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz",
-            "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==",
+            "version": "0.3.5",
+            "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz",
+            "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==",
             "dev": true,
             "dependencies": {
                 "@jridgewell/gen-mapping": "^0.3.0",
@@ -3975,6 +3941,15 @@
                 "eslint-scope": "5.1.1"
             }
         },
+        "node_modules/@nicolo-ribaudo/semver-v6": {
+            "version": "6.3.3",
+            "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/semver-v6/-/semver-v6-6.3.3.tgz",
+            "integrity": "sha512-3Yc1fUTs69MG/uZbJlLSI3JISMn2UV2rg+1D/vROUqZyh3l6iYHCs7GMp+M40ZD7yOdDbYjJcU1oTJhrc+dGKg==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/@nodelib/fs.scandir": {
             "version": "2.1.5",
             "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4020,39 +3995,6 @@
                 "semver": "^7.3.5"
             }
         },
-        "node_modules/@npmcli/fs/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@npmcli/fs/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@npmcli/fs/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/@npmcli/move-file": {
             "version": "1.1.2",
             "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
@@ -4112,9 +4054,9 @@
             }
         },
         "node_modules/@sinonjs/fake-timers": {
-            "version": "10.1.0",
-            "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.1.0.tgz",
-            "integrity": "sha512-w1qd368vtrwttm1PRJWPW1QHlbmHrVDGs1eBH/jZvRPUFS4MNXV9Q33EQdjOdeAxZ7O8+3wM7zxztm2nfUSyKw==",
+            "version": "10.3.0",
+            "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
+            "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
             "dev": true,
             "dependencies": {
                 "@sinonjs/commons": "^3.0.0"
@@ -4296,9 +4238,9 @@
             "dev": true
         },
         "node_modules/@types/node": {
-            "version": "20.3.1",
-            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz",
-            "integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==",
+            "version": "20.3.3",
+            "resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.3.tgz",
+            "integrity": "sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==",
             "dev": true
         },
         "node_modules/@types/parse-json": {
@@ -4366,17 +4308,17 @@
             "dev": true
         },
         "node_modules/@typescript-eslint/eslint-plugin": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.11.tgz",
-            "integrity": "sha512-XxuOfTkCUiOSyBWIvHlUraLw/JT/6Io1365RO6ZuI88STKMavJZPNMU0lFcUTeQXEhHiv64CbxYxBNoDVSmghg==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz",
+            "integrity": "sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==",
             "dev": true,
             "dependencies": {
                 "@eslint-community/regexpp": "^4.4.0",
-                "@typescript-eslint/scope-manager": "5.59.11",
-                "@typescript-eslint/type-utils": "5.59.11",
-                "@typescript-eslint/utils": "5.59.11",
+                "@typescript-eslint/scope-manager": "5.61.0",
+                "@typescript-eslint/type-utils": "5.61.0",
+                "@typescript-eslint/utils": "5.61.0",
                 "debug": "^4.3.4",
-                "grapheme-splitter": "^1.0.4",
+                "graphemer": "^1.4.0",
                 "ignore": "^5.2.0",
                 "natural-compare-lite": "^1.4.0",
                 "semver": "^7.3.7",
@@ -4399,48 +4341,15 @@
                 }
             }
         },
-        "node_modules/@typescript-eslint/eslint-plugin/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@typescript-eslint/eslint-plugin/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/@typescript-eslint/parser": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.11.tgz",
-            "integrity": "sha512-s9ZF3M+Nym6CAZEkJJeO2TFHHDsKAM3ecNkLuH4i4s8/RCPnF5JRip2GyviYkeEAcwGMJxkqG9h2dAsnA1nZpA==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.61.0.tgz",
+            "integrity": "sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==",
             "dev": true,
             "dependencies": {
-                "@typescript-eslint/scope-manager": "5.59.11",
-                "@typescript-eslint/types": "5.59.11",
-                "@typescript-eslint/typescript-estree": "5.59.11",
+                "@typescript-eslint/scope-manager": "5.61.0",
+                "@typescript-eslint/types": "5.61.0",
+                "@typescript-eslint/typescript-estree": "5.61.0",
                 "debug": "^4.3.4"
             },
             "engines": {
@@ -4460,13 +4369,13 @@
             }
         },
         "node_modules/@typescript-eslint/scope-manager": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.11.tgz",
-            "integrity": "sha512-dHFOsxoLFtrIcSj5h0QoBT/89hxQONwmn3FOQ0GOQcLOOXm+MIrS8zEAhs4tWl5MraxCY3ZJpaXQQdFMc2Tu+Q==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.61.0.tgz",
+            "integrity": "sha512-W8VoMjoSg7f7nqAROEmTt6LoBpn81AegP7uKhhW5KzYlehs8VV0ZW0fIDVbcZRcaP3aPSW+JZFua+ysQN+m/Nw==",
             "dev": true,
             "dependencies": {
-                "@typescript-eslint/types": "5.59.11",
-                "@typescript-eslint/visitor-keys": "5.59.11"
+                "@typescript-eslint/types": "5.61.0",
+                "@typescript-eslint/visitor-keys": "5.61.0"
             },
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4477,13 +4386,13 @@
             }
         },
         "node_modules/@typescript-eslint/type-utils": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.11.tgz",
-            "integrity": "sha512-LZqVY8hMiVRF2a7/swmkStMYSoXMFlzL6sXV6U/2gL5cwnLWQgLEG8tjWPpaE4rMIdZ6VKWwcffPlo1jPfk43g==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.61.0.tgz",
+            "integrity": "sha512-kk8u//r+oVK2Aj3ph/26XdH0pbAkC2RiSjUYhKD+PExemG4XSjpGFeyZ/QM8lBOa7O8aGOU+/yEbMJgQv/DnCg==",
             "dev": true,
             "dependencies": {
-                "@typescript-eslint/typescript-estree": "5.59.11",
-                "@typescript-eslint/utils": "5.59.11",
+                "@typescript-eslint/typescript-estree": "5.61.0",
+                "@typescript-eslint/utils": "5.61.0",
                 "debug": "^4.3.4",
                 "tsutils": "^3.21.0"
             },
@@ -4504,9 +4413,9 @@
             }
         },
         "node_modules/@typescript-eslint/types": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.11.tgz",
-            "integrity": "sha512-epoN6R6tkvBYSc+cllrz+c2sOFWkbisJZWkOE+y3xHtvYaOE6Wk6B8e114McRJwFRjGvYdJwLXQH5c9osME/AA==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.61.0.tgz",
+            "integrity": "sha512-ldyueo58KjngXpzloHUog/h9REmHl59G1b3a5Sng1GfBo14BkS3ZbMEb3693gnP1k//97lh7bKsp6/V/0v1veQ==",
             "dev": true,
             "engines": {
                 "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -4517,13 +4426,13 @@
             }
         },
         "node_modules/@typescript-eslint/typescript-estree": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.11.tgz",
-            "integrity": "sha512-YupOpot5hJO0maupJXixi6l5ETdrITxeo5eBOeuV7RSKgYdU3G5cxO49/9WRnJq9EMrB7AuTSLH/bqOsXi7wPA==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.61.0.tgz",
+            "integrity": "sha512-Fud90PxONnnLZ36oR5ClJBLTLfU4pIWBmnvGwTbEa2cXIqj70AEDEmOmpkFComjBZ/037ueKrOdHuYmSFVD7Rw==",
             "dev": true,
             "dependencies": {
-                "@typescript-eslint/types": "5.59.11",
-                "@typescript-eslint/visitor-keys": "5.59.11",
+                "@typescript-eslint/types": "5.61.0",
+                "@typescript-eslint/visitor-keys": "5.61.0",
                 "debug": "^4.3.4",
                 "globby": "^11.1.0",
                 "is-glob": "^4.0.3",
@@ -4543,51 +4452,18 @@
                 }
             }
         },
-        "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/@typescript-eslint/utils": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.11.tgz",
-            "integrity": "sha512-didu2rHSOMUdJThLk4aZ1Or8IcO3HzCw/ZvEjTTIfjIrcdd5cvSIwwDy2AOlE7htSNp7QIZ10fLMyRCveesMLg==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.61.0.tgz",
+            "integrity": "sha512-mV6O+6VgQmVE6+xzlA91xifndPW9ElFW8vbSF0xCT/czPXVhwDewKila1jOyRwa9AE19zKnrr7Cg5S3pJVrTWQ==",
             "dev": true,
             "dependencies": {
                 "@eslint-community/eslint-utils": "^4.2.0",
                 "@types/json-schema": "^7.0.9",
                 "@types/semver": "^7.3.12",
-                "@typescript-eslint/scope-manager": "5.59.11",
-                "@typescript-eslint/types": "5.59.11",
-                "@typescript-eslint/typescript-estree": "5.59.11",
+                "@typescript-eslint/scope-manager": "5.61.0",
+                "@typescript-eslint/types": "5.61.0",
+                "@typescript-eslint/typescript-estree": "5.61.0",
                 "eslint-scope": "^5.1.1",
                 "semver": "^7.3.7"
             },
@@ -4602,46 +4478,13 @@
                 "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
             }
         },
-        "node_modules/@typescript-eslint/utils/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@typescript-eslint/utils/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/@typescript-eslint/utils/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/@typescript-eslint/visitor-keys": {
-            "version": "5.59.11",
-            "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.11.tgz",
-            "integrity": "sha512-KGYniTGG3AMTuKF9QBD7EIrvufkB6O6uX3knP73xbKLMpH+QRPcgnCxjWXSHjMRuOxFLovljqQgQpR0c7GvjoA==",
+            "version": "5.61.0",
+            "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.61.0.tgz",
+            "integrity": "sha512-50XQ5VdbWrX06mQXhy93WywSFZZGsv3EOjq+lqp6WC2t+j3mb6A9xYVdrRxafvK88vg9k9u+CT4l6D8PEatjKg==",
             "dev": true,
             "dependencies": {
-                "@typescript-eslint/types": "5.59.11",
+                "@typescript-eslint/types": "5.61.0",
                 "eslint-visitor-keys": "^3.3.0"
             },
             "engines": {
@@ -5059,9 +4902,9 @@
             }
         },
         "node_modules/acorn-globals/node_modules/acorn": {
-            "version": "8.8.2",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
-            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "version": "8.9.0",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
+            "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
             "dev": true,
             "bin": {
                 "acorn": "bin/acorn"
@@ -5608,6 +5451,15 @@
                 "node": ">=8"
             }
         },
+        "node_modules/babel-loader/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/babel-plugin-istanbul": {
             "version": "6.1.1",
             "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
@@ -5653,6 +5505,15 @@
                 "@babel/core": "^7.0.0-0"
             }
         },
+        "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/babel-plugin-polyfill-corejs3": {
             "version": "0.8.1",
             "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.1.tgz",
@@ -6078,9 +5939,9 @@
             }
         },
         "node_modules/caniuse-lite": {
-            "version": "1.0.30001503",
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001503.tgz",
-            "integrity": "sha512-Sf9NiF+wZxPfzv8Z3iS0rXM1Do+iOy2Lxvib38glFX+08TCYYYGR5fRJXk4d77C4AYwhUjgYgMsMudbh2TqCKw==",
+            "version": "1.0.30001512",
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001512.tgz",
+            "integrity": "sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw==",
             "dev": true,
             "funding": [
                 {
@@ -6489,6 +6350,12 @@
             "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
             "dev": true
         },
+        "node_modules/colorpare": {
+            "version": "2.2.0",
+            "resolved": "https://registry.npmjs.org/colorpare/-/colorpare-2.2.0.tgz",
+            "integrity": "sha512-GVtxzF1YKkeoKgYa2PBOOS6reC/D1qewhv9se+8Nkh0zfY/JNaIuc2OdORLP8p+4GP7Z9IDYrkjz9XUak6imxQ==",
+            "dev": true
+        },
         "node_modules/combined-stream": {
             "version": "1.0.8",
             "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6699,18 +6566,6 @@
                 "webpack": "^4.27.0 || ^5.0.0"
             }
         },
-        "node_modules/css-loader/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/css-loader/node_modules/schema-utils": {
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -6729,27 +6584,6 @@
                 "url": "https://opencollective.com/webpack"
             }
         },
-        "node_modules/css-loader/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/css-loader/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/css-minimizer-webpack-plugin": {
             "version": "1.3.0",
             "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-1.3.0.tgz",
@@ -7597,6 +7431,15 @@
                 "url": "https://opencollective.com/webpack"
             }
         },
+        "node_modules/css-minimizer-webpack-plugin/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/css-minimizer-webpack-plugin/node_modules/stylehacks": {
             "version": "4.0.3",
             "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-4.0.3.tgz",
@@ -8184,9 +8027,9 @@
             }
         },
         "node_modules/dotenv": {
-            "version": "16.2.0",
-            "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.2.0.tgz",
-            "integrity": "sha512-jcq2vR1DY1+QA+vH58RIrWLDZOifTGmyQJWzP9arDUbgZcySdzuBb1WvhWZzZtiXgfm+GW2pjBqStqlfpzq7wQ==",
+            "version": "16.3.1",
+            "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
+            "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
             "dev": true,
             "engines": {
                 "node": ">=12"
@@ -8225,9 +8068,9 @@
             }
         },
         "node_modules/electron-to-chromium": {
-            "version": "1.4.432",
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.432.tgz",
-            "integrity": "sha512-yz3U/khQgAFT2HURJA3/F4fKIyO2r5eK09BQzBZFd6BvBSSaRuzKc2ZNBHtJcO75/EKiRYbVYJZ2RB0P4BuD2g==",
+            "version": "1.4.449",
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.449.tgz",
+            "integrity": "sha512-TxLRpRUj/107ATefeP8VIUWNOv90xJxZZbCW/eIbSZQiuiFANCx2b7u+GbVc9X4gU+xnbvypNMYVM/WArE1DNQ==",
             "dev": true
         },
         "node_modules/emittery": {
@@ -8295,9 +8138,9 @@
             }
         },
         "node_modules/envinfo": {
-            "version": "7.8.1",
-            "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz",
-            "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==",
+            "version": "7.10.0",
+            "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.10.0.tgz",
+            "integrity": "sha512-ZtUjZO6l5mwTHvc1L9+1q5p/R3wTopcfqMW8r5t8SJSKqeVI/LtajORwRFEKpEFuekjD0VBjwu1HMxL4UalIRw==",
             "dev": true,
             "bin": {
                 "envinfo": "dist/cli.js"
@@ -8523,9 +8366,9 @@
             }
         },
         "node_modules/eslint-plugin-vue": {
-            "version": "9.14.1",
-            "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.14.1.tgz",
-            "integrity": "sha512-LQazDB1qkNEKejLe/b5a9VfEbtbczcOaui5lQ4Qw0tbRBbQYREyxxOV5BQgNDTqGPs9pxqiEpbMi9ywuIaF7vw==",
+            "version": "9.15.1",
+            "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.15.1.tgz",
+            "integrity": "sha512-CJE/oZOslvmAR9hf8SClTdQ9JLweghT6JCBQNrT2Iel1uVw0W0OLJxzvPd6CxmABKCvLrtyDnqGV37O7KQv6+A==",
             "dev": true,
             "dependencies": {
                 "@eslint-community/eslint-utils": "^4.3.0",
@@ -8540,42 +8383,9 @@
                 "node": "^14.17.0 || >=16.0.0"
             },
             "peerDependencies": {
-                "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0"
-            }
-        },
-        "node_modules/eslint-plugin-vue/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/eslint-plugin-vue/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
+                "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0"
             }
         },
-        "node_modules/eslint-plugin-vue/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/eslint-scope": {
             "version": "5.1.1",
             "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
@@ -8834,50 +8644,23 @@
                 "node": ">= 4"
             }
         },
-        "node_modules/eslint/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/eslint/node_modules/optionator": {
-            "version": "0.9.1",
-            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
-            "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+            "version": "0.9.3",
+            "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
+            "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
             "dev": true,
             "dependencies": {
+                "@aashutoshrathi/word-wrap": "^1.2.3",
                 "deep-is": "^0.1.3",
                 "fast-levenshtein": "^2.0.6",
                 "levn": "^0.4.1",
                 "prelude-ls": "^1.2.1",
-                "type-check": "^0.4.0",
-                "word-wrap": "^1.2.3"
+                "type-check": "^0.4.0"
             },
             "engines": {
                 "node": ">= 0.8.0"
             }
         },
-        "node_modules/eslint/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/eslint/node_modules/supports-color": {
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -8902,12 +8685,6 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
-        "node_modules/eslint/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/espree": {
             "version": "7.3.1",
             "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",
@@ -9112,9 +8889,9 @@
             "dev": true
         },
         "node_modules/fast-glob": {
-            "version": "3.2.12",
-            "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
-            "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==",
+            "version": "3.3.0",
+            "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz",
+            "integrity": "sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==",
             "dev": true,
             "dependencies": {
                 "@nodelib/fs.stat": "^2.0.2",
@@ -9255,21 +9032,21 @@
             "dev": true
         },
         "node_modules/flow-parser": {
-            "version": "0.209.0",
-            "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.209.0.tgz",
-            "integrity": "sha512-uD7Du+9xC/gGnOyk3kANQmtgWWKANWcKGJ84Wu0NSjTaVING3GqUAsywUPAl3fEYKLVVIcDWiaQ8+R6qzghwmA==",
+            "version": "0.211.0",
+            "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.211.0.tgz",
+            "integrity": "sha512-Ftqkqisn4MA8u+1I7KGYz35y/RtLsRETsK4qrH6KkDUjxnC4mgq3CcXbckHpGyfTErqMyVhJnlJ56feEn9Cn7A==",
             "dev": true,
             "engines": {
                 "node": ">=0.4.0"
             }
         },
         "node_modules/flow-remove-types": {
-            "version": "2.209.0",
-            "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.209.0.tgz",
-            "integrity": "sha512-p8Tvy95IunOvO0PVSb/rqxUqVXRS+G9aLSkDU56eGNTJ4lEdPbnXd+LCUUb3Ntl5t0L0Llndja6cQqjovC1qaQ==",
+            "version": "2.211.0",
+            "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.211.0.tgz",
+            "integrity": "sha512-R5NA46R8/2UTbRnl2vwcZk1MyASKh60sXUM/ekMBgu/lIgAhMCQo8PMpqNEAe/Wn2Sr0siourTb8dbW/6e9aPA==",
             "dev": true,
             "dependencies": {
-                "flow-parser": "^0.209.0",
+                "flow-parser": "^0.211.0",
                 "pirates": "^3.0.2",
                 "vlq": "^0.2.1"
             },
@@ -9624,10 +9401,10 @@
             "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
             "dev": true
         },
-        "node_modules/grapheme-splitter": {
-            "version": "1.0.4",
-            "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
-            "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
+        "node_modules/graphemer": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+            "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
             "dev": true
         },
         "node_modules/growly": {
@@ -10510,6 +10287,15 @@
                 "node": ">=8"
             }
         },
+        "node_modules/istanbul-lib-instrument/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/istanbul-lib-report": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
@@ -10548,6 +10334,15 @@
                 "url": "https://github.com/sponsors/sindresorhus"
             }
         },
+        "node_modules/istanbul-lib-report/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/istanbul-lib-report/node_modules/supports-color": {
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -11869,33 +11664,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/jest-snapshot/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/jest-snapshot/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/jest-snapshot/node_modules/supports-color": {
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -11908,12 +11676,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/jest-snapshot/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/jest-util": {
             "version": "29.5.0",
             "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.5.0.tgz",
@@ -12329,9 +12091,9 @@
             }
         },
         "node_modules/jsdom/node_modules/acorn": {
-            "version": "8.8.2",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
-            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "version": "8.9.0",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
+            "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
             "dev": true,
             "bin": {
                 "acorn": "bin/acorn"
@@ -12341,15 +12103,14 @@
             }
         },
         "node_modules/jsdom/node_modules/escodegen": {
-            "version": "2.0.0",
-            "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz",
-            "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==",
+            "version": "2.1.0",
+            "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+            "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
             "dev": true,
             "dependencies": {
                 "esprima": "^4.0.1",
                 "estraverse": "^5.2.0",
-                "esutils": "^2.0.2",
-                "optionator": "^0.8.1"
+                "esutils": "^2.0.2"
             },
             "bin": {
                 "escodegen": "bin/escodegen.js",
@@ -12700,13 +12461,13 @@
             }
         },
         "node_modules/magic-string": {
-            "version": "0.30.0",
-            "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz",
-            "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==",
+            "version": "0.30.1",
+            "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.1.tgz",
+            "integrity": "sha512-mbVKXPmS0z0G4XqFDCTllmDQ6coZzn94aMlb0o/A4HEHJCKcanlDZwYJgwnkmgD3jyWhUgj9VsPrfd972yPffA==",
             "dev": true,
             "optional": true,
             "dependencies": {
-                "@jridgewell/sourcemap-codec": "^1.4.13"
+                "@jridgewell/sourcemap-codec": "^1.4.15"
             },
             "engines": {
                 "node": ">=12"
@@ -13097,39 +12858,6 @@
                 "which": "^2.0.2"
             }
         },
-        "node_modules/node-notifier/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/node-notifier/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/node-notifier/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/node-releases": {
             "version": "2.0.12",
             "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.12.tgz",
@@ -13191,9 +12919,9 @@
             }
         },
         "node_modules/nwsapi": {
-            "version": "2.2.5",
-            "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.5.tgz",
-            "integrity": "sha512-6xpotnECFy/og7tKSBVmUNft7J3jyXAka4XvG6AUhFWRz+Q/Ljus7znJAA3bxColfQLdS+XsjoodtJfCgeTEFQ==",
+            "version": "2.2.6",
+            "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.6.tgz",
+            "integrity": "sha512-vSZ4miHQ4FojLjmz2+ux4B0/XA16jfwt/LBzIUftDpRd8tujHFkXjMyLwjS08fIZCzesj2z7gJukOKJwqebJAQ==",
             "dev": true
         },
         "node_modules/object-assign": {
@@ -13581,9 +13309,9 @@
             }
         },
         "node_modules/pirates": {
-            "version": "4.0.5",
-            "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
-            "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==",
+            "version": "4.0.6",
+            "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+            "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
             "dev": true,
             "engines": {
                 "node": ">= 6"
@@ -13884,18 +13612,6 @@
                 "webpack": "^4.0.0 || ^5.0.0"
             }
         },
-        "node_modules/postcss-loader/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/postcss-loader/node_modules/schema-utils": {
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -13914,27 +13630,6 @@
                 "url": "https://opencollective.com/webpack"
             }
         },
-        "node_modules/postcss-loader/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/postcss-loader/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/postcss-merge-longhand": {
             "version": "5.1.7",
             "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz",
@@ -15148,9 +14843,9 @@
             "dev": true
         },
         "node_modules/sanitize-html": {
-            "version": "2.10.0",
-            "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.10.0.tgz",
-            "integrity": "sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==",
+            "version": "2.11.0",
+            "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.11.0.tgz",
+            "integrity": "sha512-BG68EDHRaGKqlsNjJ2xUB7gpInPA8gVx/mvjO743hZaeMCZ2DwzW7xvsqZ+KNU4QKwj86HJ3uu2liISf2qBBUA==",
             "dev": true,
             "dependencies": {
                 "deepmerge": "^4.2.2",
@@ -15183,9 +14878,9 @@
             }
         },
         "node_modules/sass": {
-            "version": "1.63.4",
-            "resolved": "https://registry.npmjs.org/sass/-/sass-1.63.4.tgz",
-            "integrity": "sha512-Sx/+weUmK+oiIlI+9sdD0wZHsqpbgQg8wSwSnGBjwb5GwqFhYNwwnI+UWZtLjKvKyFlKkatRK235qQ3mokyPoQ==",
+            "version": "1.63.6",
+            "resolved": "https://registry.npmjs.org/sass/-/sass-1.63.6.tgz",
+            "integrity": "sha512-MJuxGMHzaOW7ipp+1KdELtqKbfAWbH7OLIdoSMnVe3EXPMTmxTmlaZDCTsgIpPCs3w99lLo9/zDKkOrJuT5byw==",
             "dev": true,
             "dependencies": {
                 "chokidar": ">=3.0.0 <4.0.0",
@@ -15236,18 +14931,6 @@
                 }
             }
         },
-        "node_modules/sass-loader/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/sass-loader/node_modules/schema-utils": {
             "version": "3.3.0",
             "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@@ -15266,27 +14949,6 @@
                 "url": "https://opencollective.com/webpack"
             }
         },
-        "node_modules/sass-loader/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/sass-loader/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/sax": {
             "version": "1.2.4",
             "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
@@ -15330,14 +14992,38 @@
             "dev": true
         },
         "node_modules/semver": {
-            "version": "6.3.0",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "version": "7.5.3",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
+            "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
             "dev": true,
+            "dependencies": {
+                "lru-cache": "^6.0.0"
+            },
             "bin": {
                 "semver": "bin/semver.js"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/semver/node_modules/lru-cache": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+            "dev": true,
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=10"
             }
         },
+        "node_modules/semver/node_modules/yallist": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+            "dev": true
+        },
         "node_modules/serialize-javascript": {
             "version": "5.0.1",
             "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz",
@@ -16041,9 +15727,9 @@
             "dev": true
         },
         "node_modules/terser": {
-            "version": "5.18.0",
-            "resolved": "https://registry.npmjs.org/terser/-/terser-5.18.0.tgz",
-            "integrity": "sha512-pdL757Ig5a0I+owA42l6tIuEycRuM7FPY4n62h44mRLRfnOxJkkOHd6i89dOpwZlpF6JXBwaAHF6yWzFrt+QyA==",
+            "version": "5.18.2",
+            "resolved": "https://registry.npmjs.org/terser/-/terser-5.18.2.tgz",
+            "integrity": "sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w==",
             "dev": true,
             "dependencies": {
                 "@jridgewell/source-map": "^0.3.3",
@@ -16147,6 +15833,15 @@
                 "url": "https://opencollective.com/webpack"
             }
         },
+        "node_modules/terser-webpack-plugin/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/terser-webpack-plugin/node_modules/webpack-sources": {
             "version": "1.4.3",
             "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
@@ -16158,9 +15853,9 @@
             }
         },
         "node_modules/terser/node_modules/acorn": {
-            "version": "8.8.2",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
-            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "version": "8.9.0",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
+            "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
             "dev": true,
             "bin": {
                 "acorn": "bin/acorn"
@@ -16292,9 +15987,9 @@
             }
         },
         "node_modules/ts-loader": {
-            "version": "9.4.3",
-            "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.3.tgz",
-            "integrity": "sha512-n3hBnm6ozJYzwiwt5YRiJZkzktftRpMiBApHaJPoWLA+qetQBAXkHqCLM6nwSdRDimqVtA5ocIkcTRLMTt7yzA==",
+            "version": "9.4.4",
+            "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.4.4.tgz",
+            "integrity": "sha512-MLukxDHBl8OJ5Dk3y69IsKVFRA/6MwzEqBgh+OXMPB/OD01KQuWPFd1WAQP8a5PeSCAxfnkhiuWqfmFJzJQt9w==",
             "dev": true,
             "dependencies": {
                 "chalk": "^4.1.0",
@@ -16362,33 +16057,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/ts-loader/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/ts-loader/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
         "node_modules/ts-loader/node_modules/supports-color": {
             "version": "7.2.0",
             "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -16401,12 +16069,6 @@
                 "node": ">=8"
             }
         },
-        "node_modules/ts-loader/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/tslib": {
             "version": "1.14.1",
             "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -16476,9 +16138,9 @@
             }
         },
         "node_modules/typescript": {
-            "version": "5.1.3",
-            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.3.tgz",
-            "integrity": "sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==",
+            "version": "5.1.6",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+            "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
             "dev": true,
             "bin": {
                 "tsc": "bin/tsc",
@@ -16773,9 +16435,9 @@
             }
         },
         "node_modules/vue-eslint-parser/node_modules/acorn": {
-            "version": "8.8.2",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
-            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "version": "8.9.0",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
+            "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
             "dev": true,
             "bin": {
                 "acorn": "bin/acorn"
@@ -16813,12 +16475,12 @@
             }
         },
         "node_modules/vue-eslint-parser/node_modules/espree": {
-            "version": "9.5.2",
-            "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz",
-            "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==",
+            "version": "9.6.0",
+            "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.0.tgz",
+            "integrity": "sha512-1FH/IiruXZ84tpUlm0aCUEwMl2Ho5ilqVh0VvQXw+byAz/4SAciyHLlfmL5WYqsvD38oymdUwBss0LtK8m4s/A==",
             "dev": true,
             "dependencies": {
-                "acorn": "^8.8.0",
+                "acorn": "^8.9.0",
                 "acorn-jsx": "^5.3.2",
                 "eslint-visitor-keys": "^3.4.1"
             },
@@ -16844,39 +16506,6 @@
             "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
             "dev": true
         },
-        "node_modules/vue-eslint-parser/node_modules/lru-cache": {
-            "version": "6.0.0",
-            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
-            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
-            "dev": true,
-            "dependencies": {
-                "yallist": "^4.0.0"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/vue-eslint-parser/node_modules/semver": {
-            "version": "7.5.2",
-            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
-            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
-            "dev": true,
-            "dependencies": {
-                "lru-cache": "^6.0.0"
-            },
-            "bin": {
-                "semver": "bin/semver.js"
-            },
-            "engines": {
-                "node": ">=10"
-            }
-        },
-        "node_modules/vue-eslint-parser/node_modules/yallist": {
-            "version": "4.0.0",
-            "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
-            "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
-            "dev": true
-        },
         "node_modules/vue-gettext": {
             "version": "2.1.12",
             "resolved": "https://registry.npmjs.org/vue-gettext/-/vue-gettext-2.1.12.tgz",
@@ -17133,9 +16762,9 @@
             }
         },
         "node_modules/webpack": {
-            "version": "5.87.0",
-            "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.87.0.tgz",
-            "integrity": "sha512-GOu1tNbQ7p1bDEoFRs2YPcfyGs8xq52yyPBZ3m2VGnXGtV9MxjrkABHm4V9Ia280OefsSLzvbVoXcfLxjKY/Iw==",
+            "version": "5.88.1",
+            "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.1.tgz",
+            "integrity": "sha512-FROX3TxQnC/ox4N+3xQoWZzvGXSuscxR32rbzjpXgEzWudJFEJBpdlkkob2ylrv5yzzufD1zph1OoFsLtm6stQ==",
             "dev": true,
             "dependencies": {
                 "@types/eslint-scope": "^3.7.3",
@@ -17305,9 +16934,9 @@
             }
         },
         "node_modules/webpack/node_modules/acorn": {
-            "version": "8.8.2",
-            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
-            "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
+            "version": "8.9.0",
+            "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
+            "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
             "dev": true,
             "bin": {
                 "acorn": "bin/acorn"
diff --git a/package.json b/package.json
index 1e7acb61897d1d683affaf18db978f9f57f728a6..d0d17fc14f7b440745f9c6c81954c05b0f15aaed 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
         "@fullcalendar/resource-timeline": "^4.3.0",
         "@fullcalendar/timegrid": "^4.3.0",
         "@playwright/test": "^1.33.0",
+        "@johmun/vue-tags-input": "^2.1.0",
         "@popperjs/core": "^2.11.2",
         "@types/jquery": "^3.5.16",
         "@types/jqueryui": "^1.12.16",
@@ -72,6 +73,7 @@
         "chart.js": "^2.9.4",
         "chartist": "0.11.4",
         "ckeditor5-math": "34.1.1",
+        "colorpare": "^2.2.0",
         "cropperjs": "1.5.9",
         "css-loader": "^5.0.1",
         "css-minimizer-webpack-plugin": "^1.1.5",
diff --git a/public/assets/images/checkered-background.png b/public/assets/images/checkered-background.png
new file mode 100644
index 0000000000000000000000000000000000000000..9f5a292d96cbf004e7371655aee0850d019f538e
Binary files /dev/null and b/public/assets/images/checkered-background.png differ
diff --git a/public/pictures/stock-images/.gitkeep b/public/pictures/stock-images/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/resources/assets/javascripts/bootstrap/stock-images.js b/resources/assets/javascripts/bootstrap/stock-images.js
new file mode 100644
index 0000000000000000000000000000000000000000..6ed6edff485fb8e332889c583a82f34938e65607
--- /dev/null
+++ b/resources/assets/javascripts/bootstrap/stock-images.js
@@ -0,0 +1,5 @@
+import StockImages from '../lib/stock-images.js';
+
+STUDIP.domReady(() => {
+    StockImages.init();
+});
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index d40d4e02b7ecd262f1ce20f679311b2987f2e9c0..915d960659d543eacff381edcc0ea9def9a0641d 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -84,6 +84,7 @@ import "./bootstrap/oer.js"
 import "./bootstrap/courseware.js"
 import "./bootstrap/responsive-navigation.js"
 import "./bootstrap/treeview.js"
+import "./bootstrap/stock-images.js"
 
 import "./mvv_course_wizard.js"
 import "./mvv.js"
diff --git a/resources/assets/javascripts/lib/stock-images.js b/resources/assets/javascripts/lib/stock-images.js
new file mode 100644
index 0000000000000000000000000000000000000000..330fbc9018598046b43390b7bea2d10a3f9faceb
--- /dev/null
+++ b/resources/assets/javascripts/lib/stock-images.js
@@ -0,0 +1,23 @@
+const StockImages = {
+    init() {
+        const stockImagesPage = document.querySelector('div.stock-images');
+        if (stockImagesPage !== null) {
+            Promise.all([window.STUDIP.Vue.load(), StockImages.plugin()]).then(
+                ([{ Vue, createApp, store }, StockImagesPlugin]) => {
+                    Vue.use(StockImagesPlugin, { store });
+                    createApp({
+                        el: stockImagesPage,
+                        render: (h) => {
+                            return h(Vue.component('StockImagesPage'), { props: {} });
+                        },
+                    });
+                }
+            );
+        }
+    },
+    plugin() {
+        return import('@/vue/plugins/stock-images.js').then(({ StockImagesPlugin }) => StockImagesPlugin);
+    },
+};
+
+export default StockImages;
diff --git a/resources/vue/components/ActiveFilter.vue b/resources/vue/components/ActiveFilter.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d4654faa9f414b392561f03ac679d6a5f6b7a293
--- /dev/null
+++ b/resources/vue/components/ActiveFilter.vue
@@ -0,0 +1,54 @@
+<template>
+    <span class="activefilter">
+        <slot></slot>
+        <button
+            @click="onRemoveActiveFilter"
+            type="button"
+            :title="$gettextInterpolate($gettext('Filter \'%{name}\' entfernen'), { name })"
+        >
+            <StudipIcon class="text-bottom" shape="decline" role="presentation" alt="" />
+        </button>
+    </span>
+</template>
+
+<script>
+import StudipIcon from './StudipIcon.vue';
+export default {
+    props: {
+        name: {
+            type: String,
+            required: true,
+        },
+    },
+    methods: {
+        onRemoveActiveFilter() {
+            this.$emit('remove');
+        },
+    },
+};
+</script>
+
+<style scoped>
+.activefilter {
+    align-items: center;
+    background-color: var(--content-color-20);
+    border: solid thin var(--black);
+    display: flex;
+    gap: 4px;
+    justify-content: space-between;
+    margin: 3px;
+    padding: 5px;
+    padding-inline-end: 0;
+    white-space: nowrap;
+}
+
+button {
+    align-items: center;
+    background-color: var(--content-color-20);
+    border: none;
+    display: flex;
+    justify-content: center;
+    margin-inline: 0;
+    padding-inline-start: 4px;
+}
+</style>
diff --git a/resources/vue/components/SearchWithFilter.vue b/resources/vue/components/SearchWithFilter.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1336e96a2f51efe3339cb263cdcb9550c1cbab7e
--- /dev/null
+++ b/resources/vue/components/SearchWithFilter.vue
@@ -0,0 +1,157 @@
+<template>
+    <div>
+        <form @submit.prevent="onSearch">
+            <slot name="filters"></slot>
+
+            <input
+                :id="`search-bar-input-${searchId}`"
+                class="search-bar-input"
+                type="text"
+                v-model="searchTerm"
+                :aria-label="$gettext('Geben Sie einen Suchbegriff mit mindestens 3 Zeichen ein.')"
+            />
+
+            <button
+                v-if="showSearchResults"
+                class="search-bar-erase"
+                type="button"
+                :title="$gettext('Suchformular zurücksetzen')"
+                @click="onReset"
+            >
+                <StudipIcon shape="decline" :size="20" />
+            </button>
+
+            <button
+                type="button"
+                :title="$gettext('Suchfilter einstellen')"
+                class="search-bar-filter"
+                :class="{ active: showFilterPanel }"
+                @click="onToggleFilterPanel"
+                :aria-controls="`search-bar-filter-panel-${searchId}`"
+                :aria-expanded="showFilterPanel ? 'true' : 'false'"
+            >
+                <StudipIcon shape="filter" :role="showFilterPanel ? 'info_alt' : 'clickable'" :size="20" alt="" />
+            </button>
+
+            <button
+                type="submit"
+                :value="$gettext('Suchen')"
+                :aria-controls="`search-bar-input-${searchId}`"
+                class="submit-search"
+                :title="$gettext('Suche starten')"
+            >
+                <StudipIcon shape="search" :size="20" role="presentation" alt="" />
+            </button>
+        </form>
+        <div :id="`search-bar-filter-panel-${searchId}`" class="filterpanel" ref="filterPanel" v-if="showFilterPanel">
+            <slot></slot>
+        </div>
+    </div>
+</template>
+
+<script>
+import StudipIcon from './StudipIcon.vue';
+
+let searchIndex = 0;
+
+export default {
+    props: {
+        query: {
+            type: String,
+            required: true,
+        },
+    },
+    components: {
+        StudipIcon,
+    },
+    data: () => ({
+        searchId: searchIndex++,
+        showFilterPanel: false,
+        searchTerm: '',
+    }),
+    computed: {
+        showSearchResults() {
+            return this.query.length > 0;
+        },
+    },
+    methods: {
+        onReset() {
+            this.searchTerm = '';
+            this.onSearch();
+        },
+        onSearch() {
+            this.$emit('search', this.searchTerm);
+        },
+        onToggleFilterPanel() {
+            this.showFilterPanel = !this.showFilterPanel;
+        },
+    },
+    mounted() {
+        this.searchTerm = this.query;
+    },
+    watch: {
+        query(searchTerm) {
+            this.searchTerm = searchTerm;
+        },
+    },
+};
+</script>
+
+<style scoped>
+form {
+    align-items: stretch;
+    border: thin solid var(--content-color-40);
+    display: flex;
+    justify-content: space-between;
+    width: 100%;
+}
+
+input {
+    border: none;
+    flex-grow: 1;
+    padding-inline-start: 0.75em;
+    width: 100%;
+}
+
+input.search-bar-input {
+    line-height: 1.5;
+    padding-block: 0.25em;
+}
+
+button {
+    align-items: center;
+    background-color: var(--content-color-20);
+    border: none;
+    border-inline-start: thin solid var(--content-color-40);
+    display: flex;
+    justify-content: center;
+    width: 2.5em;
+}
+
+button.active {
+    background-color: var(--base-color);
+}
+
+button.search-bar-erase {
+    background-color: var(--white);
+    border-inline-start: none;
+}
+
+.search-bar-filter--remove {
+    margin-inline-start: 5px;
+}
+
+.filterpanel {
+    width: calc(100% + 2px);
+    background-color: var(--white);
+    border: thin solid var(--content-color-40);
+    border-top: none;
+    box-sizing: border-box;
+    padding: 10px;
+}
+
+.filterpanel::before,
+.filterpanel::after {
+    right: 50px;
+}
+</style>
diff --git a/resources/vue/components/courseware/CoursewareFileChooser.vue b/resources/vue/components/courseware/CoursewareFileChooser.vue
index 020068411768f32155aead495d6f61a38e9affe2..6571b3430e0de5618673c3661ea3f78dc52c4cb2 100644
--- a/resources/vue/components/courseware/CoursewareFileChooser.vue
+++ b/resources/vue/components/courseware/CoursewareFileChooser.vue
@@ -128,6 +128,12 @@ export default {
                 this.getFolderFiles();
             }
         },
+        value(newValue, oldValue) {
+            if (newValue === '') {
+                this.selectedFolderId = '';
+                this.currentValue = '';
+            }
+        },
     },
 };
 </script>
diff --git a/resources/vue/components/courseware/CoursewareHeadlineBlock.vue b/resources/vue/components/courseware/CoursewareHeadlineBlock.vue
index abce90bbce0cccc7db2d9e69da7d0e57d9670d40..c993300ede2c6225fafd10590388062e7e550d99 100644
--- a/resources/vue/components/courseware/CoursewareHeadlineBlock.vue
+++ b/resources/vue/components/courseware/CoursewareHeadlineBlock.vue
@@ -103,7 +103,7 @@
                                     v-model="currentTextColor"
                                 >
                                     <template #open-indicator="selectAttributes">
-                                        <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span>
+                                        <span v-bind="selectAttributes"><studip-icon shape="arr_1down" :size="10"/></span>
                                     </template>
                                     <template #no-options>
                                         {{ $gettext('Es steht keine Auswahl zur Verfügung.') }}
@@ -126,7 +126,7 @@
                                     v-model="currentTextBackgroundColor"
                                 >
                                     <template #open-indicator="selectAttributes">
-                                        <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span>
+                                        <span v-bind="selectAttributes"><studip-icon shape="arr_1down" :size="10"/></span>
                                     </template>
                                     <template #no-options>
                                         {{ $gettext('Es steht keine Auswahl zur Verfügung.') }}
@@ -144,7 +144,7 @@
                                     {{ $gettext('Icon') }}
                                     <studip-select :clearable="false" :options="icons" v-model="currentIcon">
                                         <template #open-indicator="selectAttributes">
-                                            <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span>
+                                            <span v-bind="selectAttributes"><studip-icon shape="arr_1down" :size="10"/></span>
                                         </template>
                                         <template #no-options>
                                             {{ $gettext('Es steht keine Auswahl zur Verfügung.') }}
@@ -167,7 +167,7 @@
                                         v-model="currentIconColor"
                                     >
                                         <template #open-indicator="selectAttributes">
-                                            <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span>
+                                            <span v-bind="selectAttributes"><studip-icon shape="arr_1down" :size="10"/></span>
                                         </template>
                                         <template #no-options>
                                             {{ $gettext('Es steht keine Auswahl zur Verfügung.') }}
@@ -207,7 +207,7 @@
                                     :clearable="false"
                                 >
                                     <template #open-indicator="selectAttributes">
-                                        <span v-bind="selectAttributes"><studip-icon shape="arr_1down" size="10"/></span>
+                                        <span v-bind="selectAttributes"><studip-icon shape="arr_1down" :size="10"/></span>
                                     </template>
                                     <template #no-options>
                                         {{ $gettext('Es steht keine Auswahl zur Verfügung.') }}
@@ -221,12 +221,43 @@
                                 </studip-select>
                             </label>
                             <label v-if="currentBackgroundType === 'image'">
-                                {{ $gettext('Hintergrundbild') }}
-                                <courseware-file-chooser
-                                    v-model="currentBackgroundImageId"
-                                    :isImage="true"
-                                    @selectFile="updateCurrentBackgroundImage"
-                                />
+
+                                <div>{{ $gettext('Hintergrundbild') }}</div>
+
+                                <template v-if="currentBackgroundImageId">
+                                    <template v-if="currentBackgroundImageType === 'file-refs'">
+                                        <StockImageThumbnail :url="currentBackgroundURL" width="8rem" contain />
+                                    </template>
+                                    <template v-if="currentBackgroundImageType === 'stock-images'">
+                                        <StockImageSelectableImageCard
+                                            :stock-image="selectedStockImage"
+                                            v-if="selectedStockImage"
+                                            />
+                                    </template>
+                                    <label>
+                                        <button class="button" type="button" @click="onClickRemoveBackgroundImage">
+                                            {{ $gettext('Bild entfernen') }}
+                                        </button>
+                                    </label>
+                                </template>
+
+                                <template v-if="!currentBackgroundImageId">
+                                    <courseware-file-chooser
+                                        v-model="currentBackgroundImageId"
+                                        :isImage="true"
+                                        @selectFile="onSelectFile"
+                                        />
+                                    <div style="margin-block-start: 1em">{{ $gettext('oder') }}</div>
+                                    <button class="button" type="button" @click="showStockImageSelector = true">
+                                        {{ $gettext('Aus dem Bilderpool auswählen') }}
+                                    </button>
+                                    <StockImageSelector
+                                        v-if="showStockImageSelector"
+                                        @close="showStockImageSelector = false"
+                                        @select="onSelectStockImage"
+                                        />
+                                </template>
+
                             </label>
                             <label v-if="currentBackgroundType === 'gradient'">
                                 {{ $gettext('Hintergrundfarbverlauf') }}
@@ -270,6 +301,9 @@ import { blockMixin } from './block-mixin.js';
 import colorMixin from '@/vue/mixins/courseware/colors.js';
 import { mapGetters, mapActions } from 'vuex';
 import contentIcons from './content-icons.js';
+import StockImageSelector from '../stock-images/SelectorDialog.vue';
+import StockImageSelectableImageCard from '../stock-images/SelectableImageCard.vue';
+import StockImageThumbnail from '../stock-images/Thumbnail.vue';
 
 export default {
     name: 'courseware-headline-block',
@@ -279,6 +313,9 @@ export default {
         CoursewareFileChooser,
         CoursewareTabs,
         CoursewareTab,
+        StockImageSelector,
+        StockImageSelectableImageCard,
+        StockImageThumbnail,
     },
     props: {
         block: Object,
@@ -299,14 +336,18 @@ export default {
             currentIconColor: '',
             currentBackgroundType: '',
             currentBackgroundImageId: '',
+            currentBackgroundImageType: '',
             currentBackgroundImage: {},
             currentBackgroundURL: '',
-            currentGradient: ''
+            currentGradient: '',
+            showStockImageSelector: false,
+            selectedStockImage: null,
         };
     },
     computed: {
         ...mapGetters({
             fileRefById: 'file-refs/byId',
+            stockImageById: 'stock-images/byId',
             urlHelper: 'urlHelper',
             relatedTermOfUse: 'terms-of-use/related',
             currentStructuralElementImageURL: 'currentStructuralElementImageURL'
@@ -347,6 +388,9 @@ export default {
         backgroundImageId() {
             return this.block?.attributes?.payload?.background_image_id;
         },
+        backgroundImageType() {
+            return this.block?.attributes?.payload?.background_image_type;
+        },
         backgroundImage() {
             return this.block?.attributes?.payload?.background_image;
         },
@@ -385,7 +429,7 @@ export default {
                 } else {
                     style['background-color'] = '#28497c';
                 }
-                
+
             }
             if (this.hasGradient) {
                 style['background-color'] = 'transparent';
@@ -424,6 +468,7 @@ export default {
     methods: {
         ...mapActions({
             loadFileRef: 'file-refs/loadById',
+            loadStockImage: 'stock-images/loadById',
             updateBlock: 'updateBlockInContainer',
         }),
         initCurrentData() {
@@ -440,32 +485,61 @@ export default {
             this.currentIconColor = this.iconColor;
             this.currentBackgroundType = this.backgroundType;
             this.currentBackgroundImageId = this.backgroundImageId;
+            this.currentBackgroundImageType = this.backgroundImageType ?? 'file-refs';
             if (this.currentBackgroundImageId !== '') {
                 this.loadFile();
             }
         },
         async loadFile() {
             const id = this.currentBackgroundImageId;
-            const options = { include: 'terms-of-use' };
-            await this.loadFileRef({ id: id, options });
-            const fileRef = this.fileRefById({ id: id });
-            if (fileRef && this.relatedTermOfUse({parent: fileRef, relationship: 'terms-of-use'}).attributes['download-condition'] === 0) {
-                this.updateCurrentBackgroundImage({
-                    id: fileRef.id,
-                    name: fileRef.attributes.name,
-                    download_url: this.urlHelper.getURL(
-                        'sendfile.php',
-                        { type: 0, file_id: fileRef.id, file_name: fileRef.attributes.name },
-                        true
-                    ),
-                });
+            const type = this.currentBackgroundImageType;
+
+            if (type === 'file-refs') {
+                const options = { include: 'terms-of-use' };
+                await this.loadFileRef({ id: id, options });
+                const fileRef = this.fileRefById({ id: id });
+                if (
+                    fileRef &&
+                    this.relatedTermOfUse({ parent: fileRef, relationship: 'terms-of-use' }).attributes[
+                        'download-condition'
+                    ] === 0
+                ) {
+                    this.updateCurrentBackgroundImage({
+                        id: fileRef.id,
+                        type: 'file-refs',
+                        name: fileRef.attributes.name,
+                        download_url: this.urlHelper.getURL(
+                            'sendfile.php',
+                            { type: 0, file_id: fileRef.id, file_name: fileRef.attributes.name },
+                            true
+                        ),
+                    });
+                }
+            } else if (type === 'stock-images') {
+                await this.loadStockImage({ id });
+                const stockImage = this.stockImageById({ id });
+                if (stockImage) {
+                    this.selectedStockImage = stockImage;
+                    this.updateCurrentBackgroundImage({
+                        id,
+                        type: 'stock-images',
+                        name: stockImage.attributes.title,
+                        download_url:
+                            stockImage.attributes['download-urls']['medium'] ??
+                            stockImage.attributes['download-urls']['small'],
+                    });
+                }
             }
         },
         updateCurrentBackgroundImage(file) {
             this.currentBackgroundImage = file;
             this.currentBackgroundImageId = file.id;
+            this.currentBackgroundImageType = file.type;
             this.currentBackgroundURL = file.download_url;
         },
+        onSelectFile(file) {
+            this.updateCurrentBackgroundImage({ ...file, type: 'file-refs' });
+        },
         storeText() {
             let attributes = {};
             attributes.payload = {};
@@ -482,13 +556,14 @@ export default {
             attributes.payload.background_color = '';
             attributes.payload.gradient = '';
             attributes.payload.background_image_id = '';
+            attributes.payload.background_image_type = '';
 
             if (this.currentBackgroundType === 'color') {
                 attributes.payload.background_color = this.currentBackgroundColor;
             }
             if (this.currentBackgroundType === 'image') {
                 attributes.payload.background_image_id = this.currentBackgroundImageId;
-            }
+                attributes.payload.background_image_type = this.currentBackgroundImageType;            }
             if (this.currentBackgroundType === 'gradient') {
                 attributes.payload.gradient = this.currentGradient;
             }
@@ -537,7 +612,23 @@ export default {
             const RGB = this.calcRGB(hex);
 
             return 'rgba(' + RGB.r + ',' + RGB.g + ',' + RGB.b + ',' + a +')';
-        }
+        },
+        onSelectStockImage(stockImage) {
+            this.updateCurrentBackgroundImage({
+                id: stockImage.id,
+                type: 'stock-images',
+                name: stockImage.attributes.title,
+                download_url:
+                    stockImage.attributes['download-urls']['medium'] ?? stockImage.attributes['download-urls']['small'],
+            });
+            this.selectedStockImage = stockImage;
+            this.showStockImageSelector = false;
+        },
+        onClickRemoveBackgroundImage() {
+            this.currentBackgroundImageId = '';
+            this.currentBackgroundImageType = '';
+            this.selectedStockImage = null;
+        },
     },
 };
 </script>
diff --git a/resources/vue/components/courseware/CoursewareSearchResults.vue b/resources/vue/components/courseware/CoursewareSearchResults.vue
index cdac1eefeaf61ca1307ed1231f72559ff1dd46c5..9c9740db67f341bce865cc718745ee7a14899d76 100644
--- a/resources/vue/components/courseware/CoursewareSearchResults.vue
+++ b/resources/vue/components/courseware/CoursewareSearchResults.vue
@@ -6,14 +6,14 @@
             buttonsClass="single-icon"
         >
             <template #buttons>
-                <studip-icon shape="search" size="24" />
+                <studip-icon shape="search" :size="24" />
             </template>
             <template #breadcrumbList>
                 <translate>Suchergebnisse</translate>
             </template>
             <template #menu>
                 <button :title="$gettext('Suchergebnisse schließen')" @click="closeResults">
-                    <studip-icon shape="decline" size="24"/>
+                    <studip-icon shape="decline" :size="24"/>
                 </button>
             </template>
         </courseware-ribbon>
diff --git a/resources/vue/components/courseware/CoursewareSearchWidget.vue b/resources/vue/components/courseware/CoursewareSearchWidget.vue
index 255c7998080e0f8d8d12aa3e0ce129700b3feef3..b8ebf0fde56cde3df0aedd845083d2c7ce7b6821 100644
--- a/resources/vue/components/courseware/CoursewareSearchWidget.vue
+++ b/resources/vue/components/courseware/CoursewareSearchWidget.vue
@@ -12,7 +12,7 @@
                             />
                             <a v-if="showSearchResults" @click.prevent="setShowSearchResults(false)"
                                 class="reset-search">
-                                <studip-icon shape="decline" size="20"></studip-icon>
+                                <studip-icon shape="decline" :size="20"></studip-icon>
                             </a>
                             <button
                                 type="submit"
@@ -21,7 +21,7 @@
                                 class="submit-search"
                                 @click="loadResults"
                             >
-                                <studip-icon shape="search" size="20"></studip-icon>
+                                <studip-icon shape="search" :size="20"></studip-icon>
                             </button>
                         </div>
                     </li>
@@ -40,7 +40,7 @@ import axios from 'axios';
 
 export default {
     name: 'courseware-search-widget',
-    components: { 
+    components: {
         StudipIcon,
         SidebarWidget,
     },
diff --git a/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue b/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue
index 7c27c53ffc77f296ff7894dc7c99956f3610f1a5..39bc47a325b11086e0e292191c354f4c9183c486 100644
--- a/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue
+++ b/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue
@@ -24,16 +24,32 @@
         <template v-slot:layout>
             <form class="default" @submit.prevent="">
                 <label>
-                        {{ $gettext('Bild') }}
+                        {{ $gettext('Bild hochladen') }}
                         <br>
                         <input class="cw-file-input" ref="upload_image" type="file" accept="image/*" @change="checkUploadFile"/>
-                        <courseware-companion-box
+                        <CoursewareCompanionBox
                             v-if="uploadFileError"
                             :msgCompanion="uploadFileError"
                             mood="sad"
                             class="cw-companion-box-in-form"
                         />
                 </label>
+                <template v-if="selectedStockImage">
+                    <StockImageSelectableImageCard :stock-image="selectedStockImage" />
+                    <label>
+                        <button class="button" type="button" @click="selectedStockImage = null">
+                            {{ $gettext('Bild entfernen') }}
+                        </button>
+                    </label>
+                </template>
+                <label v-else>
+                    {{ $gettext('oder') }}
+                    <br>
+                    <button class="button" type="button" @click="showStockImageSelector = true">
+                        {{ $gettext('Aus dem Bilderpool auswählen') }}
+                    </button>
+                    <StockImageSelector v-if="showStockImageSelector" @close="showStockImageSelector = false" @select="onSelectStockImage" />
+                </label>
                 <label>
                     {{ $gettext('Farbe') }}
                     <studip-select
@@ -44,7 +60,7 @@
                     >
                         <template #open-indicator="selectAttributes">
                             <span v-bind="selectAttributes"
-                                ><studip-icon shape="arr_1down" size="10"
+                                ><studip-icon shape="arr_1down" :size="10"
                             /></span>
                         </template>
                         <template #no-options>
@@ -116,6 +132,9 @@
 </template>
 
 <script>
+import CoursewareCompanionBox from './CoursewareCompanionBox.vue';
+import StockImageSelectableImageCard from '../stock-images/SelectableImageCard.vue';
+import StockImageSelector from '../stock-images/SelectorDialog.vue';
 import StudipSelect from './../StudipSelect.vue';
 import StudipWizardDialog from './../StudipWizardDialog.vue';
 import colorMixin from '@/vue/mixins/courseware/colors.js';
@@ -125,6 +144,9 @@ export default {
     name: 'courseware-shelf-dialog-add',
     mixins: [colorMixin],
     components: {
+        CoursewareCompanionBox,
+        StockImageSelectableImageCard,
+        StockImageSelector,
         StudipWizardDialog,
         StudipSelect,
     },
@@ -144,7 +166,9 @@ export default {
             },
             addWizardData: {},
             uploadFileError: '',
-            requirements: []
+            requirements: [],
+            showStockImageSelector: false,
+            selectedStockImage: null,
         }
     },
     computed: {
@@ -171,6 +195,7 @@ export default {
             companionSuccess: 'companionSuccess',
             createCoursewareUnit: 'courseware-units/create',
             setShowUnitAddDialog: 'setShowUnitAddDialog',
+            setStockImageForStructuralElement: 'setStockImageForStructuralElement',
             loadStructuralElementById: 'courseware-structural-elements/loadById',
             uploadImageForStructuralElement: 'uploadImageForStructuralElement',
         }),
@@ -200,6 +225,7 @@ export default {
                 this.uploadFileError = this.$gettext('Diese Datei ist kein Bild. Bitte wählen Sie ein Bild aus.');
             } else {
                 this.uploadFileError = '';
+                this.selectedStockImage = null;
             }
         },
         async createUnit() {
@@ -239,19 +265,33 @@ export default {
             const newElementId = this.lastCreateCoursewareUnit.relationships['structural-element'].data.id
             await this.loadStructuralElementById({ id: newElementId });
             let newStructuralElement = this.structuralElementById({id: newElementId});
-            if (file) {
-                this.uploadImageForStructuralElement({
-                    structuralElement: newStructuralElement,
-                    file,
-                }).then(() => {
-                    this.loadStructuralElementById({id: newStructuralElement.id, options: {include: 'children'}});
-                })
-                .catch((error) => {
-                    console.error(error);
-                    this.companionError({ info: this.$gettext('Das Bild für das neue Lernmaterial konnte nicht gespeichert werden.') });
-                });
+
+            try {
+                if (file) {
+                    await this.uploadImageForStructuralElement({
+                        structuralElement: newStructuralElement,
+                        file,
+                    });
+                } else if (this.selectedStockImage) {
+                    await this.setStockImageForStructuralElement({
+                        structuralElement: newStructuralElement,
+                        stockImage: this.selectedStockImage,
+                    })
+                }
+
+                this.loadStructuralElementById({id: newStructuralElement.id, options: {include: 'children'}});
+            } catch(error) {
+                console.error(error);
+                this.companionError({ info: this.$gettext('Das Bild für das neue Lernmaterial konnte nicht gespeichert werden.') });
             }
-        }
+        },
+        onSelectStockImage(stockImage) {
+            if (this.$refs?.upload_image) {
+                this.$refs.upload_image.value = null;
+            }
+            this.selectedStockImage = stockImage;
+            this.showStockImageSelector = false;
+        },
     },
     watch: {
         addWizardData: {
@@ -274,4 +314,4 @@ export default {
         }
     }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue
index edf15aabea78ca1f62b24713be120af81aa2b6b8..3ee6ca2f3d28cbbe02fe19fb53cb9812002837a8 100644
--- a/resources/vue/components/courseware/CoursewareStructuralElement.vue
+++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue
@@ -39,7 +39,7 @@
                                     <studip-icon
                                         v-if="complete"
                                         shape="accept"
-                                        role="info"   
+                                        role="info"
                                         :title="$gettext('Diese Seite wurde von Ihnen vollständig bearbeitet')"
                                     />
                                     <span
@@ -254,7 +254,7 @@
                                         >
                                             <template #open-indicator="selectAttributes">
                                                 <span v-bind="selectAttributes"
-                                                    ><studip-icon shape="arr_1down" size="10"
+                                                    ><studip-icon shape="arr_1down" :size="10"
                                                 /></span>
                                             </template>
                                             <template #no-options>
@@ -320,29 +320,35 @@
                             </courseware-tab>
                             <courseware-tab :name="textEdit.image" :index="2">
                                 <form class="default" @submit.prevent="">
-                                    <img
-                                        v-if="showPreviewImage"
-                                        :src="image"
-                                        class="cw-structural-element-image-preview"
-                                        :alt="$gettext('Vorschaubild')"
-                                    />
-                                    <label v-if="showPreviewImage">
-                                        <button class="button" @click="deleteImage" v-translate>Bild löschen</button>
-                                    </label>
-                                    <div v-if="uploadFileError" class="messagebox messagebox_error">
-                                        {{ uploadFileError }}
-                                    </div>
-                                    <label v-if="!showPreviewImage">
+                                    <template v-if="hasImage">
                                         <img
-                                            v-if="uploadImageURL"
-                                            :src="uploadImageURL"
+                                            :src="image"
                                             class="cw-structural-element-image-preview"
                                             :alt="$gettext('Vorschaubild')"
-                                        />
-                                        <div v-else class="cw-structural-element-image-preview-placeholder"></div>
-                                        {{ $gettext('Bild hochladen') }}
-                                        <input class="cw-file-input" ref="upload_image" type="file" accept="image/*" @change="checkUploadFile" />
-                                    </label>
+                                            />
+                                        <label>
+                                            <button class="button" @click="deleteImage" v-translate>Bild löschen</button>
+                                        </label>
+                                    </template>
+
+                                    <div v-else class="cw-structural-element-image-preview-placeholder"></div>
+
+                                    <div v-if="uploadFileError" class="messagebox messagebox_error">
+                                        {{ uploadFileError }}
+                                    </div>
+
+                                    <div v-show="!hasImage">
+                                        <label>
+                                            {{ $gettext('Bild hochladen') }}
+                                            <input class="cw-file-input" ref="upload_image" type="file" accept="image/*" @change="checkUploadFile" />
+                                        </label>
+                                        {{ $gettext('oder') }}
+                                        <br>
+                                        <button class="button" type="button" @click="showStockImageSelector = true">
+                                            {{ $gettext('Aus dem Bilderpool auswählen') }}
+                                        </button>
+                                        <StockImageSelector v-if="showStockImageSelector" @close="showStockImageSelector = false" @select="onSelectStockImage" />
+                                    </div>
                                 </form>
                             </courseware-tab>
                             <courseware-tab v-if="(inCourse && !isTask) || inContent" :name="textEdit.approval" :index="3">
@@ -662,6 +668,7 @@ import wizardMixin from '@/vue/mixins/courseware/wizard.js';
 import CoursewareDateInput from './CoursewareDateInput.vue';
 import { FocusTrap } from 'focus-trap-vue';
 import IsoDate from './IsoDate.vue';
+import StockImageSelector from '../stock-images/SelectorDialog.vue';
 import StudipDialog from '../StudipDialog.vue';
 import draggable from 'vuedraggable';
 import { mapActions, mapGetters } from 'vuex';
@@ -688,6 +695,7 @@ export default {
         CoursewareDateInput,
         FocusTrap,
         IsoDate,
+        StockImageSelector,
         StudipDialog,
         draggable,
     },
@@ -758,6 +766,8 @@ export default {
             keyboardSelected: null,
             assistiveLive: '',
             uploadImageURL: null,
+            showStockImageSelector: false,
+            selectedStockImage: null,
         };
     },
 
@@ -885,11 +895,21 @@ export default {
         },
 
         image() {
+            if (this.selectedStockImage) {
+                return this.selectedStockImage.attributes['download-urls'].small
+            }
+            if (this.uploadImageURL) {
+                return this.uploadImageURL;
+            }
             return this.structuralElement.relationships?.image?.meta?.['download-url'] ?? null;
         },
 
-        showPreviewImage() {
-            return this.image !== null && this.deletingPreviewImage === false;
+        imageType() {
+            return this.structuralElement.relationships?.image?.data?.type ?? null;
+        },
+
+        hasImage() {
+            return (this.image || this.selectedStockImage ) && this.deletingPreviewImage === false;
         },
 
         structuralElementLoaded() {
@@ -1195,7 +1215,7 @@ export default {
             if (this.structuralElementLoaded) {
                 return this.progressData?.[this.structuralElement.id].progress.self;
             }
-            
+
             return 0;
         },
         progressTitle() {
@@ -1216,6 +1236,7 @@ export default {
             uploadImageForStructuralElement: 'uploadImageForStructuralElement',
             deleteImageForStructuralElement: 'deleteImageForStructuralElement',
             companionSuccess: 'companionSuccess',
+            setStockImageForStructuralElement: 'setStockImageForStructuralElement',
             showElementEditDialog: 'showElementEditDialog',
             showElementAddDialog: 'showElementAddDialog',
             showElementExportDialog: 'showElementExportDialog',
@@ -1326,6 +1347,7 @@ export default {
             this.uploadImageURL = null;
             this.uploadFileError = this.checkUploadImageFile(this.$refs?.upload_image?.files[0]);
             if (this.uploadFileError === '') {
+                this.deletingPreviewImage = false;
                 this.uploadImageURL = window.URL.createObjectURL(file);
             }
         },
@@ -1349,24 +1371,30 @@ export default {
             if (!this.blocked) {
                 await this.lockObject({ id: this.currentId, type: 'courseware-structural-elements' });
             }
+
             const file = this.$refs?.upload_image?.files[0];
-            if (file) {
-                if (file.size > 2097152) {
-                    return false;
+            try {
+                this.uploadFileError = '';
+                if (file) {
+                    await this.uploadImageForStructuralElement({
+                        structuralElement: this.currentElement,
+                        file,
+                    });
+                } else if (this.selectedStockImage) {
+                    await this.setStockImageForStructuralElement({
+                        structuralElement: this.currentElement,
+                        stockImage: this.selectedStockImage,
+                    })
+                } else if (this.deletingPreviewImage) {
+                    await this.deleteImageForStructuralElement(this.currentElement);
                 }
 
-                this.uploadFileError = '';
-                this.uploadImageForStructuralElement({
-                    structuralElement: this.currentElement,
-                    file,
-                }).catch((error) => {
-                    console.error(error);
-                    this.uploadFileError = this.$gettext('Fehler beim Hochladen der Datei.');
-                });
-                await this.loadStructuralElement(this.currentElement.id);
-            } else if (this.deletingPreviewImage) {
-                await this.deleteImageForStructuralElement(this.currentElement);
+                this.loadStructuralElement(this.currentElement.id);
+            } catch(error) {
+                console.error(error);
+                this.uploadFileError = this.$gettext('Das Bild für das neue Lernmaterial konnte nicht gespeichert werden.');
             }
+
             this.showElementEditDialog(false);
 
             if (this.currentElement.attributes['release-date'] !== '') {
@@ -1379,10 +1407,13 @@ export default {
                     new Date(this.currentElement.attributes['withdraw-date']).getTime() / 1000;
             }
 
-            await this.updateStructuralElement({
-                element: this.currentElement,
-                id: this.currentId,
-            });
+            const element = {
+                id: this.currentElement.id,
+                type: this.currentElement.type,
+                attributes: this.currentElement.attributes,
+            };
+
+            await this.updateStructuralElement({ element, id: this.currentId});
             await this.unlockObject({ id: this.currentId, type: 'courseware-structural-elements' });
             this.$emit('select', this.currentId);
             this.initCurrent();
@@ -1645,7 +1676,15 @@ export default {
                     , {containerTitle: container.attributes.title, pos: currentIndex + 1, listLength: this.containerList.length}
                 );
             this.storeSort();
-        }
+        },
+        onSelectStockImage(stockImage) {
+            if (this.$refs?.upload_image) {
+                this.$refs.upload_image.value = null;
+            }
+            this.selectedStockImage = stockImage;
+            this.showStockImageSelector = false;
+            this.deletingPreviewImage = false;
+        },
     },
     created() {
         this.pluginManager.registerComponentsLocally(this);
diff --git a/resources/vue/components/courseware/CoursewareUnitItemDialogLayout.vue b/resources/vue/components/courseware/CoursewareUnitItemDialogLayout.vue
index 4f49d968381751fed7f6bcdcd28ba48f4ee6eac6..cfe9099500cf1e575e13eca7f38e2b397b50dcdc 100644
--- a/resources/vue/components/courseware/CoursewareUnitItemDialogLayout.vue
+++ b/resources/vue/components/courseware/CoursewareUnitItemDialogLayout.vue
@@ -61,7 +61,7 @@
                     >
                         <template #open-indicator="selectAttributes">
                             <span v-bind="selectAttributes"
-                                ><studip-icon shape="arr_1down" size="10"
+                                ><studip-icon shape="arr_1down" :size="10"
                             /></span>
                         </template>
                         <template #no-options>
@@ -194,4 +194,4 @@ export default {
         this.initData();
     }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/resources/vue/components/stock-images/ActionsWidget.vue b/resources/vue/components/stock-images/ActionsWidget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..565c709db182a708d5723d946860a70a5d645ed4
--- /dev/null
+++ b/resources/vue/components/stock-images/ActionsWidget.vue
@@ -0,0 +1,35 @@
+<template>
+    <SidebarWidget :title="$gettext('Aktionen')">
+        <template #content>
+            <ul class="widget-list widget-links">
+                <li>
+                    <studip-icon shape="upload" class="widget-action-icon" />
+                    <button @click="onUploadClick">{{ $gettext('Bild hinzufügen') }}</button>
+                </li>
+            </ul>
+        </template>
+    </SidebarWidget>
+</template>
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+
+export default {
+    components: {
+        SidebarWidget,
+    },
+    methods: {
+        onUploadClick() {
+            this.$emit('initiateUpload');
+        },
+    },
+};
+</script>
+<style scoped>
+.widget-list li {
+    position: relative;
+}
+.widget-action-icon {
+    position: absolute;
+    left: 0;
+}
+</style>
diff --git a/resources/vue/components/stock-images/AttributesFieldset.vue b/resources/vue/components/stock-images/AttributesFieldset.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a88501c13b71690f66417a57d24f63719d00d489
--- /dev/null
+++ b/resources/vue/components/stock-images/AttributesFieldset.vue
@@ -0,0 +1,74 @@
+<template>
+    <div>
+        <label class="studiprequired">
+            {{ $gettext('Titel') }}<span :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true" class="asterisk">*</span>
+            <input type="text" required v-model="title" />
+        </label>
+        <label class="studiprequired">
+            {{ $gettext('Beschreibung') }}<span :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true" class="asterisk">*</span>
+            <textarea required v-model="description" />
+        </label>
+        <label class="studiprequired">
+            {{ $gettext('Erstellt durch') }}<span :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true" class="asterisk">*</span>
+            <input type="text" required v-model="author" />
+        </label>
+        <label class="studiprequired">
+            {{ $gettext('Lizenz') }}<span :title="$gettext('Dies ist ein Pflichtfeld')" aria-hidden="true" class="asterisk">*</span>
+            <textarea required v-model="license" />
+        </label>
+        <label>
+            {{ $gettext('Tags') }}
+            <TagsInput v-model="tags" :suggestions="suggestedTags" />
+        </label>
+    </div>
+</template>
+<script>
+import TagsInput from './TagsInput.vue';
+
+export default {
+    props: ['metadata', 'suggestedTags'],
+    components: { TagsInput },
+    computed: {
+        author: {
+            get() {
+                return this.metadata.author;
+            },
+            set(author) {
+                this.$emit('change', { ...this.metadata, author });
+            },
+        },
+        description: {
+            get() {
+                return this.metadata.description;
+            },
+            set(description) {
+                this.$emit('change', { ...this.metadata, description });
+            },
+        },
+        license: {
+            get() {
+                return this.metadata.license;
+            },
+            set(license) {
+                this.$emit('change', { ...this.metadata, license });
+            },
+        },
+        tags: {
+            get() {
+                return this.metadata.tags;
+            },
+            set(tags) {
+                this.$emit('change', { ...this.metadata, tags });
+            },
+        },
+        title: {
+            get() {
+                return this.metadata.title;
+            },
+            set(title) {
+                this.$emit('change', { ...this.metadata, title });
+            },
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/ColorFilterWidget.vue b/resources/vue/components/stock-images/ColorFilterWidget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b816bdb33a6b63bc9c61c1c28b2734d7327be8be
--- /dev/null
+++ b/resources/vue/components/stock-images/ColorFilterWidget.vue
@@ -0,0 +1,77 @@
+<template>
+    <SidebarWidget :title="$gettext('Farbe')">
+        <template #content>
+            <studip-select multiple v-model="selectedColors" :options="selectableColors" @input="onVueSelectInput" label="name">
+                <template #open-indicator>
+                    <span><studip-icon shape="arr_1down" :size="10" /></span>
+                </template>
+
+                <template #option="{ name, hex }">
+                    <span class="vs__option-color" :style="{ 'background-color': hex }"></span>
+                    <span>{{ name }}</span>
+                </template>
+
+                <template #selected-option="{ name, hex }">
+                    <span class="vs__option-color" :style="{ 'background-color': hex }" :title="name"></span>
+                </template>
+
+                <template #no-options>{{ $gettext('Keine Auswahlmöglichkeiten') }}</template>
+            </studip-select>
+        </template>
+    </SidebarWidget>
+</template>
+<script>
+import { colors as selectableColors } from './colors.js';
+import SidebarWidget from '../SidebarWidget.vue';
+import { orientations } from './filters.js';
+
+export default {
+    model: {
+        prop: 'filters',
+        event: 'change',
+    },
+    props: {
+        filters: {
+            type: Object,
+            required: true,
+        },
+    },
+    components: {
+        SidebarWidget,
+    },
+    data: () => ({
+        selectedColors: [],
+    }),
+    computed: {
+        selectableColors: () => selectableColors,
+    },
+    methods: {
+        onVueSelectInput(selectedColors) {
+            const colors = selectedColors.map(({ hex }) => hex);
+            this.$emit('change', { ...this.filters, colors });
+        },
+    },
+    mounted() {
+        this.selectedColors = this.selectableColors.filter(({ hex }) => this.filters.colors.includes(hex));
+    },
+    watch: {
+        filters: {
+            handler(newValue) {
+                this.selectedColors = this.selectableColors.filter(({ hex }) => this.filters.colors.includes(hex));
+            },
+            deep: true,
+        },
+    },
+};
+</script>
+
+<!-- <style scoped>
+.stock-images-filters-color-swatch {
+    box-shadow: 0 0 0 1px var(--base-color-20);
+    box-sizing: border-box;
+    display: inline-block;
+    width: 20px;
+    height: 20px;
+    transition: all 0.1s;
+}
+</style> -->
diff --git a/resources/vue/components/stock-images/EditDialog.vue b/resources/vue/components/stock-images/EditDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..68c71e2331033c5e063568f9817c13ee21dc54aa
--- /dev/null
+++ b/resources/vue/components/stock-images/EditDialog.vue
@@ -0,0 +1,97 @@
+<template>
+    <studip-dialog
+        v-if="stockImage"
+        height="720"
+        width="960"
+        :title="$gettext('Bild bearbeiten')"
+        @close="onCancel"
+        closeClass="cancel"
+        :closeText="$gettext('Schließen')"
+    >
+        <template v-slot:dialogContent>
+            <form id="stock-images-edit-form" class="default" @submit.prevent="onSubmit">
+                <div>
+                    <ThumbnailCard
+                        :chdate="new Date(stockImage.attributes.chdate)"
+                        :height="stockImage.attributes.height"
+                        :mime-type="stockImage.attributes['mime-type']"
+                        :mkdate="new Date(stockImage.attributes.mkdate)"
+                        :size="stockImage.attributes.size"
+                        :url="stockImage.attributes['download-urls'].small"
+                        :width="stockImage.attributes.width"
+                    />
+                </div>
+
+                <div>
+                    <AttributesFieldset :metadata="metadata" :suggested-tags="suggestedTags" @change="onChange" />
+                </div>
+            </form>
+        </template>
+
+        <template #dialogButtons>
+            <button form="stock-images-edit-form" type="submit" class="button accept">
+                {{ $gettext('Speichern') }}
+            </button>
+        </template>
+    </studip-dialog>
+</template>
+<script>
+import ThumbnailCard from './ThumbnailCard.vue';
+import AttributesFieldset from './AttributesFieldset.vue';
+
+export default {
+    props: ['stockImage', 'suggestedTags'],
+    components: { AttributesFieldset, ThumbnailCard },
+    data: () => ({
+        metadata: {},
+    }),
+    methods: {
+        onCancel() {
+            this.$emit('cancel');
+        },
+        onChange(metadata) {
+            this.metadata = metadata;
+        },
+        onSubmit() {
+            this.$emit('confirm', { ...this.metadata });
+        },
+        resetLocalCopy() {
+            const {
+                title = '',
+                description = '',
+                author = '',
+                license = '',
+                tags = [],
+            } = this.stockImage?.attributes ?? [];
+            this.metadata = { title, description, author, license, tags };
+        },
+    },
+    mounted() {
+        this.resetLocalCopy();
+    },
+    watch: {
+        stockImage() {
+            this.resetLocalCopy();
+        },
+    },
+};
+</script>
+
+<style scoped>
+form {
+    display: flex;
+    height: 100%;
+    gap: 1.5em;
+}
+
+form > *:first-child {
+    flex-basis: 200px;
+    flex-grow: 0;
+    overflow: hidden;
+}
+
+form > *:last-child {
+    flex-basis: 30em;
+    flex-grow: 1;
+}
+</style>
diff --git a/resources/vue/components/stock-images/ImagesList.vue b/resources/vue/components/stock-images/ImagesList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7d2c0db9b73f198f7bececea425cd9cfbaae7b98
--- /dev/null
+++ b/resources/vue/components/stock-images/ImagesList.vue
@@ -0,0 +1,190 @@
+<template>
+    <div>
+        <table class="default">
+            <caption>
+                <div class="caption-container">
+                    <div>
+                        <studip-icon shape="folder-public-full" :size="30" alt="" />
+                        <span>{{ caption }}</span>
+                    </div>
+                </div>
+            </caption>
+            <thead>
+                <tr>
+                    <th>
+                        <label>
+                            <input type="checkbox" ref="checkAll" :checked="allChecked" @change="onCheckedAllChange" />
+                            <span class="sr-only">{{ $gettext('Alle Bilder auswählen') }}</span>
+                        </label>
+                    </th>
+                    <th>{{ $gettext('Name') }}</th>
+                    <th>{{ $gettext('Format') }}</th>
+                    <th>{{ $gettext('Größe') }}</th>
+                    <th>{{ $gettext('Abmessungen') }}</th>
+                </tr>
+            </thead>
+            <tbody v-if="paged.length">
+                <ImagesListItem
+                    :stock-image="stockImage"
+                    v-for="stockImage in paged"
+                    :key="stockImage.id"
+                    :is-checked="checkedImages.includes(stockImage.id)"
+                    @checked="$emit('checked', stockImage)"
+                    @search="(query) => $emit('search', query)"
+                    @select="$emit('select', stockImage)"
+                />
+            </tbody>
+            <tbody v-else>
+                <tr>
+                    <td colspan="5">
+                        <span v-if="query.length">{{
+                            $gettext('Zu diesem Suchbegriff konnten keine Bilder gefunden werden.')
+                        }}</span>
+                        <span v-else>{{ $gettext('Es konnten keine Bilder gefunden werden.') }}</span>
+                    </td>
+                </tr>
+            </tbody>
+            <tfoot v-if="paged.length">
+                <tr>
+                    <td colspan="5">
+                        <button
+                            type="button"
+                            class="button"
+                            @click="showConfirmDelete = true"
+                            :disabled="!checkedImages.length"
+                        >
+                            {{ $gettext('Löschen') }}
+                        </button>
+                    </td>
+                </tr>
+            </tfoot>
+        </table>
+
+        <studip-dialog
+            v-if="showConfirmDelete"
+            :title="
+                this.$ngettext(
+                    'Ausgewähltes Bild unwideruflich löschen?',
+                    'Ausgewählte Bilder unwideruflich löschen?',
+                    checkedImages.length
+                )
+            "
+            :question="
+                $ngettext(
+                    'Möchten Sie das ausgewählte Bild wirklich löschen?',
+                    'Möchten Sie die ausgewählten Bilder wirklich löschen?',
+                    checkedImages.length
+                )
+            "
+            height="200"
+            width="450"
+            @confirm="onDelete"
+            @close="showConfirmDelete = false"
+        ></studip-dialog>
+    </div>
+</template>
+
+<script>
+import ImagesListItem from './ImagesListItem.vue';
+import { mapActions } from 'vuex';
+
+export default {
+    props: {
+        checkedImages: {
+            type: Array,
+            required: true,
+        },
+        perPage: {
+            type: Number,
+            default: 10,
+        },
+        page: {
+            type: Number,
+            default: 1,
+        },
+        query: {
+            type: String,
+            default: '',
+        },
+        stockImages: {
+            type: Array,
+            required: true,
+        },
+    },
+    components: {
+        ImagesListItem,
+    },
+    data: () => ({
+        latestMkdate: null,
+        showConfirmDelete: false,
+    }),
+    computed: {
+        allChecked() {
+            return this.paged.length && this.paged.every(({ id }) => this.checkedImages.includes(id));
+        },
+        caption() {
+            const n = this.stockImages.length;
+            return this.$gettextInterpolate(this.$ngettext('%{ n } Bild gefunden', '%{ n } Bilder gefunden', n), { n });
+        },
+        paged() {
+            return this.stockImages.slice((this.page - 1) * this.perPage, this.page * this.perPage);
+        },
+        totalItems() {
+            return this.stockImages.length;
+        },
+    },
+    methods: {
+        ...mapActions({ deleteStockImage: 'studip/stockImages/delete' }),
+        checkAll() {
+            this.paged
+                .filter(({ id }) => !this.checkedImages.includes(id))
+                .forEach((image) => this.$emit('checked', image));
+        },
+        checkNone() {
+            this.paged
+                .filter(({ id }) => this.checkedImages.includes(id))
+                .forEach((image) => this.$emit('checked', image));
+        },
+        onCheckedAllChange() {
+            this.allChecked ? this.checkNone() : this.checkAll();
+        },
+        onDelete() {
+            const checkedImages = [...this.checkedImages];
+            this.showConfirmDelete = false;
+            this.checkNone();
+            Promise.allSettled(checkedImages.map((id) => this.deleteStockImage(id))).then(() => {
+                this.revalidatePage();
+            });
+        },
+        revalidatePage() {
+            if (this.totalItems < this.page * this.perPage) {
+                this.$emit('open-page', Math.ceil(this.totalItems / this.perPage));
+            }
+        },
+    },
+    watch: {
+        checkedImages({ length }) {
+            this.$refs.checkAll.indeterminate = 0 < length && length < this.paged.length;
+        },
+    },
+};
+</script>
+
+<style scoped>
+table.default {
+    height: 100%;
+}
+
+.caption-container div {
+    display: flex;
+    gap: 0.5em;
+}
+
+thead th input {
+    margin-inline: 1em;
+}
+
+thead th:first-child {
+    width: 3em;
+}
+</style>
diff --git a/resources/vue/components/stock-images/ImagesListItem.vue b/resources/vue/components/stock-images/ImagesListItem.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1e1dda29f698c041acb3ffb7cb1906ace94b6c45
--- /dev/null
+++ b/resources/vue/components/stock-images/ImagesListItem.vue
@@ -0,0 +1,162 @@
+<template>
+    <tr @click="onSelect">
+        <td>
+            <label>
+                <input type="checkbox" :checked="isChecked" @change="onCheckboxChange" />
+                <span class="sr-only">{{
+                    $gettextInterpolate($gettext('%{context} auswählen'), { context: stockImage.attributes.title })
+                }}</span>
+            </label>
+        </td>
+        <td>
+            <div>
+                <Thumbnail
+                    v-if="thumbnailUrl"
+                    :url="thumbnailUrl"
+                    width="6rem"
+                    style="background: var(--light-gray-color-40)"
+                    contain
+                />
+                <div>
+                    <div>{{ stockImage.attributes.title }}</div>
+                    <div>
+                        <span class="stock-image-author">{{ stockImage.attributes.author }}</span>
+                        <span class="stock-image-tags">
+                            <button
+                                type="button"
+                                class="stock-image-tag"
+                                v-for="tag in stockImage.attributes.tags"
+                                :key="tag"
+                                @click="onTagClick(tag)"
+                            >
+                                {{ tag }}
+                            </button>
+                        </span>
+                    </div>
+
+                    <ul class="stock-image-palette" :title="$gettext('Bildfarben')" role="presentation">
+                        <li
+                            v-for="(color, index) in palette"
+                            :key="index"
+                            :style="`background-color: rgb(${color[0]} ${color[1]} ${color[2]});`"
+                            :alt="color.join(',')"
+                        ></li>
+                    </ul>
+                </div>
+            </div>
+        </td>
+        <td>
+            <studip-icon shape="file-pic" alt="" />
+            {{ imageFormat(stockImage) }}
+        </td>
+        <td><studip-file-size :size="stockImage.attributes.size" /></td>
+        <td>{{ stockImage.attributes.width }} × {{ stockImage.attributes.height }}</td>
+    </tr>
+</template>
+<script>
+import Thumbnail from './Thumbnail.vue';
+import { getFormat } from './format.js';
+
+export default {
+    props: {
+        stockImage: {
+            type: Object,
+            required: true,
+        },
+        isChecked: {
+            type: Boolean,
+            default: false,
+        },
+    },
+    components: {
+        Thumbnail,
+    },
+    computed: {
+        palette() {
+            return this.stockImage.attributes.palette ?? [];
+        },
+        thumbnailUrl() {
+            return (
+                this.stockImage.attributes['download-urls'].small ??
+                this.stockImage.attributes['download-urls'].original
+            );
+        },
+    },
+    methods: {
+        imageFormat(image) {
+            return getFormat(image.attributes['mime-type']);
+        },
+        onCheckboxChange() {
+            this.$emit('checked');
+        },
+        onSelect({ target }) {
+            if (!['INPUT', 'LABEL', 'BUTTON'].includes(target.tagName)) {
+                this.$emit('select');
+            }
+        },
+        onTagClick(tag) {
+            this.$emit('search', tag);
+        },
+    },
+};
+</script>
+
+<style scoped>
+tr > td:nth-child(1) {
+    height: 100%;
+    min-height: 100%;
+    padding: 0;
+}
+
+tr > td:nth-child(1) > label {
+    height: 100%;
+    min-height: 100%;
+    display: flex;
+    padding-inline: 1em;
+}
+
+tr > td:nth-child(2) > div {
+    align-items: center;
+    display: flex;
+    flex-direction: row;
+    gap: 1rem;
+}
+
+tr > td:nth-child(2) > div div:last-child {
+    flex: 1;
+    margin-inline-end: 1rem;
+}
+
+tr > td:nth-child(3) img {
+    vertical-align: middle;
+}
+
+.stock-image-author,
+.stock-image-tags {
+    font-size: 0.8em;
+    opacity: 0.75;
+}
+.stock-image-tags {
+    display: flex;
+    gap: 0.5em;
+    margin-block: 0.5em;
+}
+.stock-image-tag {
+    background-color: var(--base-color);
+    border: none;
+    color: var(--white);
+    cursor: pointer;
+    padding: 0.25em 0.5em;
+}
+.stock-image-palette {
+    display: flex;
+    width: 100%;
+    height: 0.25em;
+    padding-inline-start: 0;
+}
+
+.stock-image-palette li {
+    display: inline;
+    flex: 1;
+}
+</style>
diff --git a/resources/vue/components/stock-images/ImagesPagination.vue b/resources/vue/components/stock-images/ImagesPagination.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2cd1621a9e1f02dbacd4941b0fc1736e6ae20d52
--- /dev/null
+++ b/resources/vue/components/stock-images/ImagesPagination.vue
@@ -0,0 +1,58 @@
+<template>
+    <div>
+        <StudipPagination
+            :style="{ visibility: totalItems <= perPage ? 'hidden' : 'visible' }"
+            :currentOffset="offset"
+            :totalItems="totalItems"
+            :itemsPerPage="perPage"
+            @updateOffset="onUpdateOffset"
+        />
+        <slot></slot>
+        <StudipPagination
+            :style="{ visibility: totalItems <= perPage ? 'hidden' : 'visible' }"
+            :currentOffset="offset"
+            :totalItems="totalItems"
+            :itemsPerPage="perPage"
+            @updateOffset="onUpdateOffset"
+        />
+    </div>
+</template>
+
+<script>
+import StudipPagination from '../StudipPagination.vue';
+
+export default {
+    components: { StudipPagination },
+    model: {
+        prop: 'page',
+        event: 'change',
+    },
+    props: {
+        stockImages: {
+            type: Array,
+            required: true,
+        },
+        page: {
+            type: Number,
+            required: true,
+        },
+        perPage: {
+            type: Number,
+            default: 10,
+        },
+    },
+    computed: {
+        offset() {
+            return this.page - 1;
+        },
+        totalItems() {
+            return this.stockImages.length;
+        },
+    },
+    methods: {
+        onUpdateOffset(offset) {
+            this.$emit('change', offset + 1);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/MetadataBox.vue b/resources/vue/components/stock-images/MetadataBox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..ac819de4c7446025ca0127970db056d2a7235144
--- /dev/null
+++ b/resources/vue/components/stock-images/MetadataBox.vue
@@ -0,0 +1,92 @@
+<template>
+    <div class="upload-metadata-box">
+        <div>
+            <ThumbnailCard
+                v-if="fileURL"
+                :height="height ?? 0"
+                :mime-type="file.type"
+                :size="file.size"
+                :url="fileURL"
+                :width="width ?? 0"
+            />
+        </div>
+        <div>
+            <AttributesFieldset :metadata="metadata" :suggested-tags="suggestedTags" @change="onChange" />
+        </div>
+    </div>
+</template>
+
+<script>
+import ThumbnailCard from './ThumbnailCard.vue';
+import AttributesFieldset from './AttributesFieldset.vue';
+import { getFormat } from './format.js';
+
+export default {
+    props: ['file', 'metadata', 'suggestedTags'],
+
+    components: { AttributesFieldset, ThumbnailCard },
+
+    data: () => ({
+        fileURL: null,
+        height: null,
+        image: null,
+        width: null,
+    }),
+
+    computed: {
+        tags: {
+            get() {
+                return this.metadata.tags;
+            },
+            set(tags) {
+                this.$set(this.metadata, 'tags', tags);
+            },
+        },
+    },
+
+    methods: {
+        onChange(metadata) {
+            this.$emit('change', metadata);
+        },
+    },
+
+    mounted() {
+        this.fileURL = URL.createObjectURL(this.file);
+        this.image = new Image();
+        this.image.onload = ({ target }) => {
+            this.height = target.height;
+            this.width = target.width;
+        };
+        this.image.src = this.fileURL;
+        this.$set(this.metadata, 'title', this.file.name);
+    },
+
+    beforeDestroy() {
+        if (this.fileURL) {
+            URL.revokeObjectURL(this.fileURL);
+        }
+    },
+};
+</script>
+
+<style scoped>
+.upload-metadata-box {
+    display: flex;
+    gap: 1em;
+}
+.upload-metadata-box > div:first-child {
+    flex-basis: 200px;
+    flex-grow: 0;
+    overflow: hidden;
+}
+.upload-metadata-box > div:last-child {
+    flex-basis: 30em;
+    flex-grow: 1;
+}
+.upload-metadata-box div:first-child ul {
+    font-size: 0.9em;
+    line-height: 1.5;
+    margin-block-start: 1em;
+    padding-inline-start: 0;
+}
+</style>
diff --git a/resources/vue/components/stock-images/NavigationWidget.vue b/resources/vue/components/stock-images/NavigationWidget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4cdc12ac4ecbfcdef98c92c2e866c7eaafde2c10
--- /dev/null
+++ b/resources/vue/components/stock-images/NavigationWidget.vue
@@ -0,0 +1,31 @@
+<template>
+    <SidebarWidget>
+        <template #content>
+            <ul
+                class="widget-list widget-links sidebar-navigation navigation-level-3"
+                :aria-label="$gettext('Dritte Navigationsebene')"
+            >
+                <li class="active">
+                    <a aria-current="page" id="nav_overview_index" class="active" :href="overviewUrl">
+                        {{ $gettext('Ãœbersicht') }}
+                    </a>
+                </li>
+            </ul>
+        </template>
+    </SidebarWidget>
+</template>
+
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+
+export default {
+    components: {
+        SidebarWidget,
+    },
+    computed: {
+        overviewUrl() {
+            return window.STUDIP.URLHelper.getURL('dispatch.php/contents/overview');
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/OrientationFilterWidget.vue b/resources/vue/components/stock-images/OrientationFilterWidget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5ae4a2c4dee06154927e5425f8ba84bbd7ab0040
--- /dev/null
+++ b/resources/vue/components/stock-images/OrientationFilterWidget.vue
@@ -0,0 +1,47 @@
+<template>
+    <SidebarWidget :title="$gettext('Seitenausrichtung')">
+        <template #content>
+            <label>
+                <span class="sr-only">{{ $gettext('Wählen Sie eine Seitenausrichtung') }}</span>
+                <select v-model="orientation" class="sidebar-selectlist">
+                    <option v-for="[value, { text }] in Object.entries(orientations)" :value="value" :key="value">
+                        {{ text }}
+                    </option>
+                </select>
+            </label>
+        </template>
+    </SidebarWidget>
+</template>
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+import { orientations } from './filters.js';
+
+export default {
+    model: {
+        prop: 'filters',
+        event: 'change',
+    },
+    props: {
+        filters: {
+            type: Object,
+            required: true,
+        },
+    },
+    components: {
+        SidebarWidget,
+    },
+    computed: {
+        orientation: {
+            get() {
+                return this.filters.orientation;
+            },
+            set(orientation) {
+                this.$emit('change', { ...this.filters, orientation });
+            }
+        },
+        orientations() {
+            return orientations;
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/Page.vue b/resources/vue/components/stock-images/Page.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b7daddc75e95083e58f89967b2c0cc9e26ade30c
--- /dev/null
+++ b/resources/vue/components/stock-images/Page.vue
@@ -0,0 +1,157 @@
+<template>
+    <div>
+        <ImagesPagination :per-page="perPage" :stock-images="filteredStockImages" v-model="page">
+            <ImagesList
+                :checked-images="checkedImages"
+                :page="page"
+                :per-page="perPage"
+                :stock-images="filteredStockImages"
+                @checked="onCheckboxChange"
+                @open-page="(newPage) => (page = newPage)"
+                @search="onSearch"
+                @select="onSelectImage"
+            />
+        </ImagesPagination>
+        <MountingPortal mountTo="#stock-images-widget" name="sidebar-stock-images">
+            <NavigationWidget />
+            <SearchWidget :query="query" @search="onSearch" />
+            <OrientationFilterWidget v-model="filters" />
+            <ColorFilterWidget v-model="filters" />
+            <ActionsWidget @initiateUpload="onUploadDialogShow" />
+        </MountingPortal>
+        <EditDialog
+            :stock-image="selectedImage"
+            :suggested-tags="suggestedTags"
+            @confirm="onEditDialogConfirm"
+            @cancel="selectedImage = null"
+        />
+        <UploadDialog
+            :show="showUpload"
+            :suggested-tags="suggestedTags"
+            @confirm="onUploadDialogConfirm"
+            @cancel="showUpload = false"
+        />
+    </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import ActionsWidget from './ActionsWidget.vue';
+import ColorFilterWidget from './ColorFilterWidget.vue';
+import EditDialog from './EditDialog.vue';
+import ImagesList from './ImagesList.vue';
+import ImagesPagination from './ImagesPagination.vue';
+import NavigationWidget from './NavigationWidget.vue';
+import OrientationFilterWidget from './OrientationFilterWidget.vue';
+import SearchWidget from './SearchWidget.vue';
+import UploadDialog from './UploadDialog.vue';
+import { searchFilterAndSortImages } from './filters.js';
+
+export default {
+    components: {
+        ActionsWidget,
+        ColorFilterWidget,
+        EditDialog,
+        ImagesList,
+        ImagesPagination,
+        NavigationWidget,
+        OrientationFilterWidget,
+        SearchWidget,
+        UploadDialog,
+    },
+    data: () => ({
+        checkedImages: [],
+        filters: {
+            orientation: 'any',
+            colors: [],
+        },
+        page: 1,
+        perPage: 10,
+        query: '',
+        selectedImage: null,
+        showUpload: false,
+    }),
+    computed: {
+        ...mapGetters({
+            stockImages: 'stock-images/all',
+            stockImagesMeta: 'stock-images/lastMeta',
+            suggestedTags: 'studip/stockImages/allTags',
+        }),
+        filteredStockImages() {
+            return searchFilterAndSortImages(this.stockImages, this.query, this.filters);
+        },
+    },
+    methods: {
+        ...mapActions({
+            createStockImage: 'studip/stockImages/create',
+            loadStockImages: 'stock-images/loadWhere',
+            updateStockImage: 'studip/stockImages/update',
+        }),
+        onCheckboxChange(image) {
+            if (!this.checkedImages.includes(image.id)) {
+                this.checkedImages.push(image.id);
+            } else {
+                this.checkedImages = this.checkedImages.filter((id) => id !== image.id);
+            }
+        },
+        onEditDialogConfirm(attributes) {
+            this.updateStockImage({ stockImage: this.selectedImage, attributes });
+            this.selectedImage = null;
+        },
+        onSearch(query) {
+            this.query = query;
+        },
+        onSelectImage(image) {
+            this.selectedImage = image;
+        },
+        onUploadDialogConfirm({ file, metadata }) {
+            this.createStockImage([file, metadata])
+                .then(() => {
+                    this.showUpload = false;
+                })
+                .catch((error) => {
+                    console.error('Could not create stock image', error);
+                });
+        },
+        onUploadDialogShow() {
+            this.showUpload = true;
+        },
+        async fetchStockImages() {
+            const loadLimit = 30;
+            await this.loadPage(0, loadLimit);
+            const total = this.stockImagesMeta.page.total;
+
+            const pages = [];
+            for (let page = 1; page * loadLimit < total; page++) {
+                pages.push(this.loadPage(page * loadLimit, loadLimit));
+            }
+
+            return Promise.all(pages);
+        },
+        loadPage(offset, limit) {
+            return this.loadStockImages({
+                filter: {},
+                options: {
+                    'page[offset]': offset,
+                    'page[limit]': limit,
+                },
+            });
+        },
+    },
+    created() {
+        this.fetchStockImages();
+    },
+    watch: {
+        query(newQuery, oldQuery) {
+            if (newQuery !== oldQuery && this.page !== 1) {
+                this.page = 1;
+            }
+        },
+        filters(newFilters, oldFilters) {
+            if (!_.isEqual(newFilters, oldFilters) && this.page !== 1) {
+                this.page = 1;
+            }
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/SearchWidget.vue b/resources/vue/components/stock-images/SearchWidget.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2288c66bfcbe657da787132bd600e75e8ec081c3
--- /dev/null
+++ b/resources/vue/components/stock-images/SearchWidget.vue
@@ -0,0 +1,77 @@
+<template>
+    <SidebarWidget :title="$gettext('Suche')">
+        <template #content>
+            <form class="sidebar-search" @submit.prevent="onSearch">
+                <ul class="needles">
+                    <li>
+                        <div class="input-group files-search">
+                            <input
+                                id="stock-images-search-widget-search"
+                                type="text"
+                                v-model="searchTerm"
+                                :aria-label="$gettext('Geben Sie einen Suchbegriff mit mindestens 3 Zeichen ein.')"
+                            />
+                            <button
+                                v-if="showSearchResults"
+                                @click.prevent="onReset"
+                                class="reset-search"
+                                :title="$gettext('Suchformular zurücksetzen')"
+                            >
+                                <studip-icon shape="decline" :size="20" role="presentation" alt="" />
+                            </button>
+                            <button
+                                type="submit"
+                                :value="$gettext('Suchen')"
+                                aria-controls="stock-images-search-widget-search"
+                                class="submit-search"
+                                :title="$gettext('Suche starten')"
+                            >
+                                <studip-icon shape="search" :size="20" role="presentation" alt="" />
+                            </button>
+                        </div>
+                    </li>
+                </ul>
+            </form>
+        </template>
+    </SidebarWidget>
+</template>
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+
+export default {
+    props: {
+        query: {
+            type: String,
+            default: '',
+        },
+    },
+    components: {
+        SidebarWidget,
+    },
+    data: () => ({
+        searchTerm: '',
+    }),
+    computed: {
+        showSearchResults() {
+            return this.query.length > 0;
+        },
+    },
+    methods: {
+        onReset() {
+            this.searchTerm = '';
+            this.onSearch();
+        },
+        onSearch() {
+            this.$emit('search', this.searchTerm);
+        },
+    },
+    mounted() {
+        this.searchTerm = this.query;
+    },
+    watch: {
+        query(searchTerm) {
+            this.searchTerm = searchTerm;
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/SelectableImageCard.vue b/resources/vue/components/stock-images/SelectableImageCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..777b0f93a1f8cc0afd35dc5cf1df1638434e75d8
--- /dev/null
+++ b/resources/vue/components/stock-images/SelectableImageCard.vue
@@ -0,0 +1,48 @@
+<template>
+    <div class="stock-images-selectable-image" tabindex="0">
+        <Thumbnail :url="thumbnailUrl" contain class="stock-images-image-card__thumbnail" width="8rem" />
+        <div>{{ stockImage.attributes?.title ?? '' }}</div>
+    </div>
+</template>
+
+<script>
+import Thumbnail from './Thumbnail.vue';
+
+export default {
+    props: {
+        stockImage: {
+            type: Object,
+            required: true,
+        },
+    },
+    components: { Thumbnail },
+    computed: {
+        thumbnailUrl() {
+            return (
+                this.stockImage.attributes['download-urls'].small ??
+                this.stockImage.attributes['download-urls'].original
+            );
+        },
+    },
+};
+</script>
+
+<style scoped>
+.stock-images-selectable-image {
+    overflow: hidden;
+    position: relative;
+}
+.stock-images-selectable-image > :last-child {
+    background: #ffffff;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    width: 8rem;
+    min-height: 3em;
+    -webkit-line-clamp: 2;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+}
+.stock-images-image-card__thumbnail {
+    background-image: url(../images/checkered-background.png);
+}
+</style>
diff --git a/resources/vue/components/stock-images/Selector.vue b/resources/vue/components/stock-images/Selector.vue
new file mode 100644
index 0000000000000000000000000000000000000000..4e34f9f6f3a33806b74a2677c59f568809ae9d45
--- /dev/null
+++ b/resources/vue/components/stock-images/Selector.vue
@@ -0,0 +1,68 @@
+<template>
+    <div>
+        <div>
+            <SelectorSearch
+                :active-filters="activeFilters"
+                :query="query"
+                @search="onSearch"
+                @update-active-filters="onUpdateActiveFilters"
+            />
+        </div>
+        <ul>
+            <li v-for="stockImage in filteredStockImages" :key="stockImage.id">
+                <SelectableImageCard :stock-image="stockImage" @click.native="onSelectImage(stockImage)" @keyup.enter.native="onSelectImage(stockImage)" />
+            </li>
+        </ul>
+    </div>
+</template>
+
+<script>
+import SelectorSearch from './SelectorSearch.vue';
+import SelectableImageCard from './SelectableImageCard.vue';
+import { searchFilterAndSortImages } from './filters.js';
+
+export default {
+    props: {
+        stockImages: {
+            type: Array,
+            required: true,
+        },
+    },
+    data: () => ({
+        activeFilters: {
+            colors: [],
+            orientation: 'landscape',
+        },
+        query: '',
+    }),
+    components: { SelectorSearch, SelectableImageCard },
+    computed: {
+        filteredStockImages() {
+            return searchFilterAndSortImages(this.stockImages, this.query, this.activeFilters);
+        },
+    },
+    methods: {
+        onUpdateActiveFilters(activeFilters) {
+            this.activeFilters = activeFilters;
+        },
+        onSearch(query) {
+            this.query = query;
+        },
+        onSelectImage(stockImage) {
+            this.$emit('select', stockImage);
+        },
+    },
+};
+</script>
+
+<style scoped>
+ul {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 1rem;
+    justify-content: flex-start;
+    align-items: center;
+    list-style: none;
+    padding: 1rem 0;
+}
+</style>
diff --git a/resources/vue/components/stock-images/SelectorDialog.vue b/resources/vue/components/stock-images/SelectorDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c6c46f8f88de0334e3645187d569c31d83d27a07
--- /dev/null
+++ b/resources/vue/components/stock-images/SelectorDialog.vue
@@ -0,0 +1,67 @@
+<template>
+    <studip-dialog
+        width="890"
+        :title="$gettext('Bild auswählen')"
+        :closeText="$gettext('Schließen')"
+        height="640"
+        @close="onClose"
+    >
+        <template v-slot:dialogContent>
+            <Selector :stock-images="stockImages" @select="onSelectImage" />
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import Selector from './Selector.vue';
+
+export default {
+    data: () => ({
+        query: '',
+        selectedImage: null,
+    }),
+    components: { Selector },
+    computed: {
+        ...mapGetters({
+            stockImages: 'stock-images/all',
+            stockImagesMeta: 'stock-images/lastMeta',
+        }),
+    },
+    methods: {
+        ...mapActions({
+            loadStockImages: 'stock-images/loadWhere',
+        }),
+        async fetchStockImages() {
+            const loadLimit = 30;
+            await this.loadPage(0, loadLimit);
+            const total = this.stockImagesMeta.page.total;
+
+            const pages = [];
+            for (let page = 1; page * loadLimit < total; page++) {
+                pages.push(this.loadPage(page * loadLimit, loadLimit));
+            }
+
+            return Promise.all(pages);
+        },
+        loadPage(offset, limit) {
+            return this.loadStockImages({
+                filter: {},
+                options: {
+                    'page[offset]': offset,
+                    'page[limit]': limit,
+                },
+            });
+        },
+        onClose() {
+            this.$emit('close');
+        },
+        onSelectImage(stockImage) {
+            this.$emit('select', stockImage);
+        },
+    },
+    created() {
+        this.fetchStockImages();
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/SelectorSearch.vue b/resources/vue/components/stock-images/SelectorSearch.vue
new file mode 100644
index 0000000000000000000000000000000000000000..849e91541a821b7672eaf70a8ba216e04cd6f025
--- /dev/null
+++ b/resources/vue/components/stock-images/SelectorSearch.vue
@@ -0,0 +1,162 @@
+<template>
+    <SearchWithFilter :query="query" @search="onSearch">
+        <template #filters>
+            <ActiveFilter
+                v-if="hasOrientationFilter"
+                :name="orientations[orientation].text"
+                @remove="onRemoveOrientationFilter()"
+            >
+                {{ orientations[orientation].text }}
+            </ActiveFilter>
+
+            <ActiveFilter
+                v-for="color in selectedColors"
+                :key="color.hex"
+                :name="$gettextInterpolate($gettext('Farbe %{color}'), { color: color.name })"
+                @remove="onRemoveColorFilter(color)"
+            >
+                <label>
+                    <b class="stock-images-color-patch" :style="`background-color: ${color.hex}`"></b>
+                </label>
+            </ActiveFilter>
+        </template>
+
+        <div class="stock-images-search-filter-panel">
+            <div>
+                <label>
+                    <div>{{ $gettext('Seitenausrichtung') }}</div>
+
+                    <select v-model="orientation">
+                        <option v-for="[key, value] in Object.entries(orientations)" :value="key" :key="`orientation-option-${key}`">
+                            {{ value.text }}
+                        </option>
+                    </select>
+                </label>
+            </div>
+
+            <div>
+                <div>{{ $gettext('Farbfilter') }}</div>
+
+                <studip-select
+                    multiple
+                    v-model="selectedColors"
+                    :options="selectableColors"
+                    label="name"
+                >
+                    <template #open-indicator>
+                        <span><studip-icon shape="arr_1down" :size="10" /></span>
+                    </template>
+
+                    <template #option="{ name, hex }">
+                        <span class="vs__option-color" :style="{ 'background-color': hex }"></span>
+                        <span>{{ name }}</span>
+                    </template>
+
+                    <template #selected-option-container>{{ ' ' }}</template>
+
+                    <template #no-options>{{ $gettext('Keine Auswahlmöglichkeiten') }}</template>
+                </studip-select>
+            </div>
+        </div>
+    </SearchWithFilter>
+</template>
+
+<script>
+import ActiveFilter from '../ActiveFilter.vue';
+import SearchWithFilter from '../SearchWithFilter.vue';
+import { colors as selectableColors } from './colors.js';
+import { orientations, similarColors } from './filters.js';
+
+export default {
+    props: {
+        activeFilters: {
+            type: Object,
+            required: true,
+        },
+        query: {
+            type: String,
+            required: true,
+        },
+    },
+    components: {
+        ActiveFilter,
+        SearchWithFilter,
+    },
+    data: () => ({
+        orientation: 'any',
+        selectedColors: [],
+    }),
+    computed: {
+        hasOrientationFilter() {
+            return this.orientation && this.orientation !== 'any';
+        },
+        orientations: () => orientations,
+        selectableColors: () => selectableColors,
+        showSearchResults() {
+            return this.query.length > 0;
+        },
+    },
+    methods: {
+        onRemoveColorFilter(color) {
+            this.selectedColors = this.selectedColors.filter((clr) => clr.hex !== color.hex);
+            this.updateActiveFilters();
+        },
+        onRemoveOrientationFilter() {
+            this.orientation = 'any';
+        },
+        onReset() {
+            this.onSearch();
+        },
+        onSearch(searchTerm = null) {
+            this.$emit('search', searchTerm);
+        },
+        resetLocalFilters() {
+            this.selectedColors = this.activeFilters?.colors
+                ? this.selectableColors.filter(({ hex }) => this.activeFilters.colors.includes(hex))
+                : [];
+            this.orientation = this.activeFilters?.orientation ?? 'any';
+        },
+        updateActiveFilters() {
+            const activeFilters = {
+                colors: this.selectedColors.map(({ hex }) => hex),
+                orientation: this.orientation,
+            };
+            this.$emit('update-active-filters', activeFilters);
+        },
+    },
+    mounted() {
+        this.resetLocalFilters();
+    },
+    watch: {
+        activeFilters() {
+            this.resetLocalFilters();
+        },
+        orientation(newVal, oldVal) {
+            this.updateActiveFilters();
+        },
+    },
+};
+</script>
+
+<style scoped>
+.stock-images-search-filter-panel {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 2rem;
+}
+.stock-images-search-filter-panel > * {
+    flex-grow: 1;
+    flex-basis: calc((30rem - 100%) * 999);
+}
+.stock-images-search-filter-panel select {
+    width: 100%;
+    max-width: 48em;
+}
+b.stock-images-color-patch {
+    border: solid thin var(--base-color-20);
+    display: inline-block;
+    vertical-align: bottom;
+    width: 20px;
+    height: 20px;
+}
+</style>
diff --git a/resources/vue/components/stock-images/TagsInput.vue b/resources/vue/components/stock-images/TagsInput.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7e7f11587767c1965e8a0d4a370b50a2e6aed875
--- /dev/null
+++ b/resources/vue/components/stock-images/TagsInput.vue
@@ -0,0 +1,90 @@
+<template>
+    <div>
+        <span class="sr-only">{{
+            $gettext('Um einen Tag zu erstellen, schließen Sie Ihre Eingabe mit der Eingabetaste ab.')
+        }}</span>
+        <TagsInput
+            v-model="tag"
+            :add-on-key="[13, ';']"
+            :autocomplete-items="filteredItems"
+            :maxlength="1000"
+            :save-on-key="[13, ';']"
+            :separators="[';']"
+            :tags="formattedTags"
+            @tags-changed="onTagsChanged"
+            :placeholder="$gettext('Tag hinzufügen')"
+        >
+            <template #tag-actions="{ index, edit, performDelete }">
+                <i
+                    tabindex="0"
+                    v-show="edit"
+                    class="ti-icon-undo"
+                    @keyup.enter="performcancelEdit(index)"
+                    @click="performcancelEdit(index)"
+                />
+                <i
+                    tabindex="0"
+                    v-show="!edit"
+                    class="ti-icon-close"
+                    @keyup.enter="performDelete(index)"
+                    @click="performDelete(index)"
+                    :title="$gettext('Tag entfernen')"
+                />
+            </template>
+        </TagsInput>
+    </div>
+</template>
+<script>
+import TagsInput from '@johmun/vue-tags-input';
+
+const fromSimpleTags = (array) => array.map((text) => ({ text }));
+const toSimpleTags = (tags) => tags.map(({ text }) => text);
+
+export default {
+    model: {
+        prop: 'tags',
+        event: 'change',
+    },
+    props: {
+        tags: {
+            type: Array,
+            default: () => [],
+        },
+        suggestions: {
+            type: Array,
+            default: () => [],
+        },
+    },
+
+    components: { TagsInput },
+
+    data: () => ({
+        tag: '',
+        formattedTags: [],
+    }),
+
+    computed: {
+        filteredItems() {
+            return fromSimpleTags(this.suggestions).filter(
+                (i) => i.text.toLowerCase().includes(this.tag.toLowerCase())
+            );
+        },
+    },
+
+    mounted() {
+        this.formattedTags = fromSimpleTags(this.tags);
+    },
+
+    methods: {
+        onTagsChanged(newTags) {
+            this.$emit('change', toSimpleTags(newTags));
+        },
+    },
+
+    watch: {
+        tags(newTags) {
+            this.formattedTags = fromSimpleTags(newTags);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/Thumbnail.vue b/resources/vue/components/stock-images/Thumbnail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..350f2548e628a3a06259266711686a5a6683b13c
--- /dev/null
+++ b/resources/vue/components/stock-images/Thumbnail.vue
@@ -0,0 +1,45 @@
+<template>
+    <div class="stock-images-thumbnail" v-if="url">
+        <div :style="{ width }">
+            <img :src="url" :style="{ 'object-fit': contain ? 'contain' : 'cover' }" role="presentation" />
+        </div>
+    </div>
+</template>
+
+<script>
+export default {
+    props: {
+        url: {
+            type: String,
+            required: true,
+        },
+        width: {
+            type: String,
+            default: '6rem',
+        },
+        contain: {
+            type: Boolean,
+            default: false
+        }
+    },
+};
+</script>
+
+<style scoped>
+.stock-images-thumbnail {
+    display: inline-flex;
+    position: relative;
+}
+.stock-images-thumbnail > div {
+    aspect-ratio: 1/1;
+    display: block;
+    overflow: hidden;
+}
+
+img {
+    display: block;
+    height: 100%;
+    max-width: 100%;
+    width: 100%;
+}
+</style>
diff --git a/resources/vue/components/stock-images/ThumbnailCard.vue b/resources/vue/components/stock-images/ThumbnailCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..201a6043d79863501b9c32e5074de638edd4aa36
--- /dev/null
+++ b/resources/vue/components/stock-images/ThumbnailCard.vue
@@ -0,0 +1,79 @@
+<template>
+    <div class="stock-images-thumbnail-card">
+        <Thumbnail :url="url" width="200px" style="background: var(--light-gray-color-40)" contain />
+        <table class="default">
+            <tbody>
+                <tr>
+                    <td>{{ $gettext('Format') }}</td>
+                    <td>
+                        <studip-icon shape="file-pic" role="presentation" alt="" />
+                        {{ format }}
+                        ({{ width }}&nbsp;×&nbsp;{{ height }})
+                    </td>
+                </tr>
+                <tr>
+                    <td>{{ $gettext('Größe') }}</td>
+                    <td>
+                        <studip-file-size :size="size" />
+                    </td>
+                </tr>
+                <tr v-if="mkdate">
+                    <td>{{ $gettext('Erstellt') }}</td>
+                    <td>
+                        <studip-date-time :timestamp="mkdate / 1000" />
+                    </td>
+                </tr>
+
+                <tr v-if="chdate">
+                    <td>{{ $gettext('Geändert') }}</td>
+                    <td>
+                        <studip-date-time :timestamp="chdate / 1000" />
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+    </div>
+</template>
+<script>
+import Thumbnail from './Thumbnail.vue';
+import { getFormat } from './format.js';
+
+export default {
+    props: {
+        chdate: {
+            type: Date,
+            required: false,
+        },
+        height: {
+            type: Number,
+            required: true,
+        },
+        mimeType: {
+            type: String,
+            required: true,
+        },
+        mkdate: {
+            type: Date,
+            required: false,
+        },
+        size: {
+            type: Number,
+            required: true,
+        },
+        url: {
+            type: String,
+            required: true,
+        },
+        width: {
+            type: Number,
+            required: true,
+        },
+    },
+    components: { Thumbnail },
+    computed: {
+        format() {
+            return getFormat(this.mimeType);
+        },
+    },
+};
+</script>
diff --git a/resources/vue/components/stock-images/UploadBox.vue b/resources/vue/components/stock-images/UploadBox.vue
new file mode 100644
index 0000000000000000000000000000000000000000..a0941dd3c1525bcee16fdcd75b071f094e74fdc0
--- /dev/null
+++ b/resources/vue/components/stock-images/UploadBox.vue
@@ -0,0 +1,76 @@
+<template>
+    <label id="stock-images-upload-box-drag-area">
+        <div class="holder">
+            <div class="box-centered">
+                <div class="icon-upload">
+                    <studip-icon shape="upload" :size="100" alt="" />
+                </div>
+                <strong>{{ $gettext('Bild auswählen oder per Drag & Drop hierher ziehen') }}</strong>
+                <div class="upload-button-holder">
+                    <input type="file" name="file" tabindex="-1" accept="image/*" ref="upload" @change="onUpload" />
+                </div>
+            </div>
+        </div>
+    </label>
+</template>
+
+<script>
+export default {
+    data: () => ({}),
+    methods: {
+        onUpload() {
+            const files = this.$refs.upload.files;
+            const file = files[0];
+            this.$emit('upload', { file });
+        },
+    },
+};
+</script>
+
+<style scoped>
+#stock-images-upload-box-drag-area {
+    background-color: var(--content-color-20);
+    height: 100%;
+    margin: -15px;
+    padding: 18px 15px 10px;
+    text-align: center;
+}
+.holder {
+    align-items: center;
+    border-color: var(--content-color-60);
+    border-radius: 0.5em;
+    border-style: dashed;
+    border-width: 1px;
+    box-sizing: border-box;
+    display: flex;
+    height: 100%;
+    justify-content: center;
+    padding-bottom: 4px;
+    padding: 0;
+}
+
+.box-centered {
+    height: auto;
+    width: 100%;
+    max-height: 100%;
+}
+
+.icon-upload + strong {
+    color: var(--base-color);
+    font-size: 1.5em;
+    line-height: 1.2;
+    display: block;
+    font-weight: 500;
+    margin: 0 0 14px;
+    text-align: center;
+    margin-left: 2em;
+    margin-right: 2em;
+}
+
+.upload-button-holder input[type='file'] {
+    opacity: 0;
+    width: 100%;
+    height: 100%;
+    padding: 0;
+}
+</style>
diff --git a/resources/vue/components/stock-images/UploadDialog.vue b/resources/vue/components/stock-images/UploadDialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d27e827510bb6b06b17fb3c6b09643f32654bf53
--- /dev/null
+++ b/resources/vue/components/stock-images/UploadDialog.vue
@@ -0,0 +1,91 @@
+<template>
+    <studip-dialog
+        v-if="show"
+        height="720"
+        width="960"
+        :title="$gettext('Bild hinzufügen')"
+        @close="onCancel"
+        closeClass="cancel"
+        :closeText="$gettext('Abbrechen')"
+    >
+        <template #dialogContent>
+            <form id="stock-images-upload-form" class="default" @submit.prevent="onSubmit">
+                <UploadBox v-if="state === STATES.IDLE" @upload="onUpload" />
+                <MetadataBox
+                    v-if="state === STATES.UPLOADED"
+                    :file="file"
+                    :metadata="metadata"
+                    :suggested-tags="suggestedTags"
+                    @change="onChangeMetadata"
+                />
+            </form>
+        </template>
+
+        <template #dialogButtons>
+            <button
+                form="stock-images-upload-form"
+                type="submit"
+                class="button accept"
+                :disabled="state !== STATES.UPLOADED"
+            >
+                {{ $gettext('Hinzufügen') }}
+            </button>
+        </template>
+    </studip-dialog>
+</template>
+<script>
+import MetadataBox from './MetadataBox.vue';
+import UploadBox from './UploadBox.vue';
+import { mapActions } from 'vuex';
+
+const STATES = { IDLE: 'idle', UPLOADED: 'uploaded' };
+
+export default {
+    props: ['show', 'suggestedTags'],
+    components: { MetadataBox, UploadBox },
+    data: () => ({
+        file: null,
+        metadata: {
+            title: '',
+            description: '',
+            author: '',
+            license: '',
+            tags: [],
+        },
+        state: STATES.IDLE,
+        STATES,
+    }),
+    methods: {
+        onCancel() {
+            this.$emit('cancel');
+            this.resetLocalCopy();
+        },
+        onChangeMetadata(metadata) {
+            this.metadata = metadata;
+        },
+        onSubmit() {
+            this.$emit('confirm', { file: this.file, metadata: this.metadata });
+        },
+        onUpload({ file }) {
+            this.file = file;
+            this.state = STATES.UPLOADED;
+        },
+        resetLocalCopy() {
+            this.file = null;
+            this.metadata = {};
+            this.state = STATES.IDLE;
+        },
+    },
+    watch: {
+        show() {
+            this.resetLocalCopy();
+        },
+    },
+};
+</script>
+
+<style scoped>
+form {
+    height: 100%;
+}
+</style>
diff --git a/resources/vue/components/stock-images/colors.js b/resources/vue/components/stock-images/colors.js
new file mode 100644
index 0000000000000000000000000000000000000000..4ba138cdea02386d1082f30cfec6ae54bb7e444c
--- /dev/null
+++ b/resources/vue/components/stock-images/colors.js
@@ -0,0 +1,18 @@
+import { $gettext } from '@/assets/javascripts/lib/gettext.js';
+
+const colors = [
+    { name: $gettext('Schwarz'), hex: '#000000' },
+    { name: $gettext('Grau'), hex: '#898f94' },
+    { name: $gettext('Weiß'), hex: '#ffffff' },
+    { name: $gettext('Gelb'), hex: '#ffbd33' },
+    { name: $gettext('Orange'), hex: '#f26e00' },
+    { name: $gettext('Braun'), hex: '#a85d45' },
+    { name: $gettext('Rot'), hex: '#d60000' },
+    { name: $gettext('Violett'), hex: '#af2d7b' },
+    { name: $gettext('Lila'), hex: '#682c8b' },
+    { name: $gettext('Blau'), hex: '#28497c' },
+    { name: $gettext('Petrol'), hex: '#129c94' },
+    { name: $gettext('Grün'), hex: '#008512' },
+];
+
+export { colors };
diff --git a/resources/vue/components/stock-images/components.js b/resources/vue/components/stock-images/components.js
new file mode 100644
index 0000000000000000000000000000000000000000..93c61d274bd0ecc60c09377fae933fe125b20377
--- /dev/null
+++ b/resources/vue/components/stock-images/components.js
@@ -0,0 +1,21 @@
+export { default as StockImagesActionsWidget } from './ActionsWidget.vue';
+export { default as StockImagesAttributesFieldset } from './AttributesFieldset.vue';
+export { default as StockImagesColorFilterWidget } from './ColorFilterWidget.vue';
+export { default as StockImagesEditDialog } from './EditDialog.vue';
+export { default as StockImagesImagesList } from './ImagesList.vue';
+export { default as StockImagesImagesListItem } from './ImagesListItem.vue';
+export { default as StockImagesImagesPagination } from './ImagesPagination.vue';
+export { default as StockImagesMetadataBox } from './MetadataBox.vue';
+export { default as StockImagesNavigationWidget } from './NavigationWidget.vue';
+export { default as StockImagesOrientationFilterWidget } from './OrientationFilterWidget.vue';
+export { default as StockImagesPage } from './Page.vue';
+export { default as StockImagesSearchWidget } from './SearchWidget.vue';
+export { default as StockImagesSelectableImageCard } from './SelectableImageCard.vue';
+export { default as StockImagesSelector } from './Selector.vue';
+export { default as StockImagesSelectorDialog } from './SelectorDialog.vue';
+export { default as StockImagesSelectorSearch } from './SelectorSearch.vue';
+export { default as StockImagesTagsInput } from './TagsInput.vue';
+export { default as StockImagesThumbnail } from './Thumbnail.vue';
+export { default as StockImagesThumbnailCard } from './ThumbnailCard.vue';
+export { default as StockImagesUploadBox } from './UploadBox.vue';
+export { default as StockImagesUploadDialog } from './UploadDialog.vue';
diff --git a/resources/vue/components/stock-images/filters.js b/resources/vue/components/stock-images/filters.js
new file mode 100644
index 0000000000000000000000000000000000000000..55cf72697baa42b1f8ea5613c5e8913a8c49121e
--- /dev/null
+++ b/resources/vue/components/stock-images/filters.js
@@ -0,0 +1,71 @@
+import { $gettext } from '@/assets/javascripts/lib/gettext.js';
+import { fromHex, rgbToCIELab, cie94 } from 'colorpare';
+
+const SQUARE_DELTA = 1.1;
+
+const isLandscape = ({ attributes: { width, height } }) => {
+    if (!(width > 0 && height > 0)) {
+        return false;
+    }
+    return width > SQUARE_DELTA * height;
+};
+
+const isPortrait = ({ attributes: { width, height } }) => {
+    if (!(width > 0 && height > 0)) {
+        return false;
+    }
+    return height > SQUARE_DELTA * width;
+};
+
+const isSquare = ({ attributes: { width, height } }) => {
+    if (!(width > 0 && height > 0)) {
+        return false;
+    }
+    return Math.max(width / height, height / width) <= SQUARE_DELTA;
+};
+
+export const orientations = {
+    any: {
+        text: $gettext('Beliebige Ausrichtung'),
+        filter: () => true,
+    },
+    landscape: { text: $gettext('Querformat'), filter: isLandscape },
+    portrait: { text: $gettext('Hochformat'), filter: isPortrait },
+    square: { text: $gettext('Quadrat'), filter: isSquare },
+};
+
+const SIMILARITY = 15;
+const toHex = (hashHex) => hashHex.substr(1);
+const toLab = (color) => color.lab();
+const toRGB = ([r, g, b]) => ({ r, g, b });
+
+export const similarColors = (filterColors) => {
+    if (!filterColors.length) {
+        return () => true;
+    }
+    const labColors = filterColors.map((color) => toLab(fromHex(toHex(color))));
+    const isSimilar = (color) => labColors.some((labColor) => cie94(labColor, color) < SIMILARITY);
+
+    return ({ attributes: { palette } }) => palette.map(toRGB).map(rgbToCIELab).some(isSimilar);
+};
+
+const filter = (stockImages, filters) => {
+    const orientation = orientations[filters.orientation] ?? orientations.any;
+    const colors = similarColors(filters.colors ?? []);
+
+    return stockImages.filter(orientation.filter).filter(colors);
+};
+
+const search = (stockImages, query) => {
+    if (!query.trim().length) {
+        return stockImages;
+    }
+
+    return stockImages.filter(({ attributes: { title, description, author, tags } }) => {
+        return [title, description, author, tags].some((field) => field.includes(query));
+    });
+};
+
+const sort = (stockImages) => _.sortBy([...stockImages], 'attributes.title');
+
+export const searchFilterAndSortImages = (stockImages, query, filters) => sort(filter(search(stockImages, query), filters));
diff --git a/resources/vue/components/stock-images/format.js b/resources/vue/components/stock-images/format.js
new file mode 100644
index 0000000000000000000000000000000000000000..f6e5b03dd39be8584bb38a2f6ce0a79c99d6180a
--- /dev/null
+++ b/resources/vue/components/stock-images/format.js
@@ -0,0 +1,14 @@
+export const getFormat = (mimeType) => {
+    switch (mimeType) {
+        case 'image/gif':
+            return 'GIF';
+        case 'image/jpeg':
+            return 'JPEG';
+        case 'image/png':
+            return 'PNG';
+        case 'image/webp':
+            return 'WebP';
+        default:
+            return '???';
+    }
+};
diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js
index 073948565fe178489843fd9cff59db5caf5fb5a0..a31686bceb45b8afffda27219fc7d1aa2cb5d2b2 100644
--- a/resources/vue/courseware-index-app.js
+++ b/resources/vue/courseware-index-app.js
@@ -8,6 +8,7 @@ import VueRouter from 'vue-router';
 import Vuex from 'vuex';
 import axios from 'axios';
 import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import { StockImagesPlugin } from './plugins/stock-images.js';
 
 const mountApp = async (STUDIP, createApp, element) => {
     const getHttpClient = () =>
@@ -165,6 +166,8 @@ const mountApp = async (STUDIP, createApp, element) => {
         store,
     });
 
+    Vue.use(StockImagesPlugin, { store });
+
     app.$mount(element);
 
     return app;
diff --git a/resources/vue/courseware-shelf-app.js b/resources/vue/courseware-shelf-app.js
index de8a438e416a7f99d162b35214d5c73eb288ac41..d0d86255f038991f8773c632ad47f55a35da6983 100644
--- a/resources/vue/courseware-shelf-app.js
+++ b/resources/vue/courseware-shelf-app.js
@@ -1,8 +1,10 @@
 import CoursewareShelfModule from './store/courseware/courseware-shelf.module';
 import ShelfApp from './components/courseware/ShelfApp.vue';
+import Vue from 'vue';
 import Vuex from 'vuex';
 import axios from 'axios';
 import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import { StockImagesPlugin } from './plugins/stock-images.js';
 
 const mountApp = async (STUDIP, createApp, element) => {
     const getHttpClient = () =>
@@ -83,6 +85,8 @@ const mountApp = async (STUDIP, createApp, element) => {
         await store.dispatch('courseware-structural-elements-shared/loadAll', { options: { include: 'owner' } });
     }
 
+    Vue.use(StockImagesPlugin, { store });
+
     const app = createApp({
         render: (h) => h(ShelfApp),
         store,
diff --git a/resources/vue/plugins/stock-images.js b/resources/vue/plugins/stock-images.js
new file mode 100644
index 0000000000000000000000000000000000000000..d99dbb385af16d906dea189099d963c53c01ea0a
--- /dev/null
+++ b/resources/vue/plugins/stock-images.js
@@ -0,0 +1,49 @@
+import axios from 'axios';
+import { mapResourceModules } from '@elan-ev/reststate-vuex';
+import stockImagesModule from '../store/stock-images.js';
+import * as components from '../components/stock-images/components.js';
+
+const JSONAPI_PATH = 'jsonapi.php/v1';
+
+export const StockImagesPlugin = {
+    install(Vue, options = {}) {
+        if (!('store' in options)) {
+            throw new Error('You must provide the vuex store via the options argument');
+        }
+        this.enhanceStore(options.store);
+        this.registerComponents(Vue);
+    },
+    enhanceStore(store) {
+        const httpClient = getHttpClient(window.STUDIP.URLHelper.getURL(JSONAPI_PATH, {}, true));
+        initializeStore(store, httpClient);
+    },
+    registerComponents(Vue) {
+        Object.entries(components).forEach(([name, component]) => {
+            const exists = Vue.component(name);
+            if (!exists) {
+                Vue.component(name, component);
+            }
+        });
+    },
+};
+
+function getHttpClient(baseURL) {
+    return axios.create({ baseURL, headers: { 'Content-Type': 'application/vnd.api+json' } });
+}
+
+function initializeStore(store, httpClient) {
+    const modules = mapResourceModules({ names: ['stock-images'], httpClient });
+    Object.entries(modules).forEach(([name, module]) => {
+        if (!store.hasModule(name)) {
+            store.registerModule(name, module);
+        }
+    });
+    if (!store.hasModule(['studip'])) {
+        store.registerModule(['studip'], { namespaced: true });
+    }
+
+    if (!store.hasModule(['studip', 'stockImages'])) {
+        store.registerModule(['studip', 'stockImages'], stockImagesModule);
+        store.commit('studip/stockImages/setHttpClient', httpClient);
+    }
+}
diff --git a/resources/vue/store/courseware/courseware-shelf.module.js b/resources/vue/store/courseware/courseware-shelf.module.js
index 0eb95fde43b4703563d67a508ba2134e8c99144f..ac5be55bcd13edee4ce3ebe6b2a1a15a92e7e9be 100644
--- a/resources/vue/store/courseware/courseware-shelf.module.js
+++ b/resources/vue/store/courseware/courseware-shelf.module.js
@@ -572,6 +572,15 @@ export const actions = {
         return dispatch('loadStructuralElement', structuralElement.id);
     },
 
+    setStockImageForStructuralElement({ dispatch, state }, { structuralElement, stockImage }) {
+        const { id, type } = structuralElement;
+        structuralElement.relationships.image = { data: { type: 'stock-images', id: stockImage.id } };
+
+        return dispatch('lockObject', { id, type })
+            .then(() => dispatch('updateStructuralElement', { element: structuralElement, id }))
+            .then(() => dispatch('lockObject', { id, type }));
+    },
+
     setImportFilesState({ commit }, state) {
         commit('setImportFilesState', state);
     },
diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js
index 64e65ce8f5f3dd04af7058a30da514e5a0b1e1bc..0f56e3e7af87e3fa9d52c9c8a113a541f46f260f 100644
--- a/resources/vue/store/courseware/courseware.module.js
+++ b/resources/vue/store/courseware/courseware.module.js
@@ -1023,6 +1023,15 @@ export const actions = {
         });
     },
 
+    setStockImageForStructuralElement({ dispatch, state }, { structuralElement, stockImage }) {
+        const { id, type } = structuralElement;
+        structuralElement.relationships.image = { data: { type: 'stock-images', id: stockImage.id } };
+
+        return dispatch('lockObject', { id, type })
+            .then(() => dispatch('updateStructuralElement', { element: structuralElement, id }))
+            .then(() => dispatch('lockObject', { id, type }));
+    },
+
     async deleteImageForStructuralElement({ dispatch, state }, structuralElement) {
         const url = `courseware-structural-elements/${structuralElement.id}/image`;
         await state.httpClient.delete(url);
diff --git a/resources/vue/store/stock-images.js b/resources/vue/store/stock-images.js
new file mode 100644
index 0000000000000000000000000000000000000000..4ba9a0bdef0aef7f06af0c59c077be6988915bd2
--- /dev/null
+++ b/resources/vue/store/stock-images.js
@@ -0,0 +1,64 @@
+const state = () => ({
+    httpClient: null,
+});
+
+const getters = {
+    allTags(state, getters, rootState, rootGetters) {
+        return Array.from(
+            rootGetters['stock-images/all'].reduce((tags, stockImage) => {
+                stockImage.attributes.tags.forEach((tag) => tags.add(tag));
+                return tags;
+            }, new Set())
+        );
+    },
+};
+
+const mutations = {
+    setHttpClient(state, httpClient) {
+        state.httpClient = httpClient;
+    },
+};
+
+const actions = {
+    async create({ dispatch, rootGetters, state }, [file, metadata]) {
+        const stockImage = {
+            type: 'stock-images',
+            attributes: {
+                title: metadata.title,
+                description: metadata.description,
+                author: metadata.author,
+                license: metadata.license,
+                tags: metadata.tags,
+            },
+        };
+        await dispatch('stock-images/create', stockImage, { root: true });
+
+        const created = rootGetters['stock-images/lastCreated'];
+        const formData = new FormData();
+        formData.append('image', file);
+
+        await state.httpClient.post(`stock-images/${created.id}/blob`, formData);
+
+        return dispatch('stock-images/loadById', created, { root: true });
+    },
+
+    async update({ dispatch, rootGetters, state }, { stockImage, attributes }) {
+        console.debug('stockImage', stockImage);
+        stockImage.attributes = { ...stockImage.attributes, ...attributes };
+        await dispatch('stock-images/update', stockImage, { root: true });
+        return dispatch('stock-images/loadById', stockImage, { root: true });
+    },
+
+    delete({ dispatch, rootGetters }, id) {
+        const record = rootGetters['stock-images/byId']({ id });
+        return dispatch('stock-images/delete', record, { root: true });
+    },
+};
+
+export default {
+    namespaced: true,
+    state,
+    getters,
+    mutations,
+    actions,
+};