From f88e5ececb7eba585ab4c563e7561136003b96ab Mon Sep 17 00:00:00 2001 From: Marcus Eibrink-Lunzenauer <lunzenauer@elan-ev.de> Date: Tue, 4 Jul 2023 10:03:28 +0000 Subject: [PATCH] Add stock images, closes #2482 Closes #2482 Merge request studip/studip!1788 --- app/controllers/stock_images.php | 37 + app/views/stock_images/index.php | 1 + cli/studip | 2 +- composer.json | 3 +- composer.lock | 272 +++-- .../5.4.7_create_stock_images_table.php | 41 + ..._add_image_type_to_structural_elements.php | 28 + lib/classes/JsonApi/RouteMap.php | 12 + .../StructuralElementsImageDelete.php | 4 +- .../StructuralElementsImageUpload.php | 3 +- .../Courseware/StructuralElementsUpdate.php | 78 +- .../JsonApi/Routes/StockImages/Authority.php | 57 + .../Routes/StockImages/StockImagesCreate.php | 101 ++ .../Routes/StockImages/StockImagesDelete.php | 32 + .../Routes/StockImages/StockImagesIndex.php | 33 + .../Routes/StockImages/StockImagesShow.php | 29 + .../Routes/StockImages/StockImagesUpdate.php | 96 ++ .../Routes/StockImages/StockImagesUpload.php | 146 +++ .../Routes/StockImages/ValidationHelpers.php | 26 + lib/classes/JsonApi/SchemaMap.php | 1 + lib/classes/JsonApi/Schemas/StockImage.php | 54 + lib/classes/StockImages/PaletteCreator.php | 19 + lib/classes/StockImages/Scaler.php | 70 ++ .../Courseware/BlockTypes/Headline.json | 3 + lib/models/Courseware/StructuralElement.php | 113 +- lib/models/StockImage.php | 118 ++ lib/navigation/ContentsNavigation.php | 7 + package-lock.json | 1047 ++++++----------- package.json | 2 + public/assets/images/checkered-background.png | Bin 0 -> 89 bytes public/pictures/stock-images/.gitkeep | 0 .../javascripts/bootstrap/stock-images.js | 5 + resources/assets/javascripts/entry-base.js | 1 + .../assets/javascripts/lib/stock-images.js | 23 + resources/vue/components/ActiveFilter.vue | 54 + resources/vue/components/SearchWithFilter.vue | 157 +++ .../courseware/CoursewareFileChooser.vue | 6 + .../courseware/CoursewareHeadlineBlock.vue | 147 ++- .../courseware/CoursewareSearchResults.vue | 4 +- .../courseware/CoursewareSearchWidget.vue | 6 +- .../courseware/CoursewareShelfDialogAdd.vue | 74 +- .../CoursewareStructuralElement.vue | 127 +- .../CoursewareUnitItemDialogLayout.vue | 4 +- .../components/stock-images/ActionsWidget.vue | 35 + .../stock-images/AttributesFieldset.vue | 74 ++ .../stock-images/ColorFilterWidget.vue | 77 ++ .../components/stock-images/EditDialog.vue | 97 ++ .../components/stock-images/ImagesList.vue | 190 +++ .../stock-images/ImagesListItem.vue | 162 +++ .../stock-images/ImagesPagination.vue | 58 + .../components/stock-images/MetadataBox.vue | 92 ++ .../stock-images/NavigationWidget.vue | 31 + .../stock-images/OrientationFilterWidget.vue | 47 + .../vue/components/stock-images/Page.vue | 157 +++ .../components/stock-images/SearchWidget.vue | 77 ++ .../stock-images/SelectableImageCard.vue | 48 + .../vue/components/stock-images/Selector.vue | 68 ++ .../stock-images/SelectorDialog.vue | 67 ++ .../stock-images/SelectorSearch.vue | 162 +++ .../vue/components/stock-images/TagsInput.vue | 90 ++ .../vue/components/stock-images/Thumbnail.vue | 45 + .../components/stock-images/ThumbnailCard.vue | 79 ++ .../vue/components/stock-images/UploadBox.vue | 76 ++ .../components/stock-images/UploadDialog.vue | 91 ++ .../vue/components/stock-images/colors.js | 18 + .../vue/components/stock-images/components.js | 21 + .../vue/components/stock-images/filters.js | 71 ++ .../vue/components/stock-images/format.js | 14 + resources/vue/courseware-index-app.js | 3 + resources/vue/courseware-shelf-app.js | 4 + resources/vue/plugins/stock-images.js | 49 + .../courseware/courseware-shelf.module.js | 9 + .../vue/store/courseware/courseware.module.js | 9 + resources/vue/store/stock-images.js | 64 + 74 files changed, 4184 insertions(+), 944 deletions(-) create mode 100644 app/controllers/stock_images.php create mode 100644 app/views/stock_images/index.php create mode 100644 db/migrations/5.4.7_create_stock_images_table.php create mode 100644 db/migrations/5.4.8_add_image_type_to_structural_elements.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/Authority.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/StockImagesCreate.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/StockImagesIndex.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/StockImagesShow.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php create mode 100644 lib/classes/JsonApi/Routes/StockImages/ValidationHelpers.php create mode 100644 lib/classes/JsonApi/Schemas/StockImage.php create mode 100644 lib/classes/StockImages/PaletteCreator.php create mode 100644 lib/classes/StockImages/Scaler.php create mode 100644 lib/models/StockImage.php create mode 100644 public/assets/images/checkered-background.png create mode 100644 public/pictures/stock-images/.gitkeep create mode 100644 resources/assets/javascripts/bootstrap/stock-images.js create mode 100644 resources/assets/javascripts/lib/stock-images.js create mode 100644 resources/vue/components/ActiveFilter.vue create mode 100644 resources/vue/components/SearchWithFilter.vue create mode 100644 resources/vue/components/stock-images/ActionsWidget.vue create mode 100644 resources/vue/components/stock-images/AttributesFieldset.vue create mode 100644 resources/vue/components/stock-images/ColorFilterWidget.vue create mode 100644 resources/vue/components/stock-images/EditDialog.vue create mode 100644 resources/vue/components/stock-images/ImagesList.vue create mode 100644 resources/vue/components/stock-images/ImagesListItem.vue create mode 100644 resources/vue/components/stock-images/ImagesPagination.vue create mode 100644 resources/vue/components/stock-images/MetadataBox.vue create mode 100644 resources/vue/components/stock-images/NavigationWidget.vue create mode 100644 resources/vue/components/stock-images/OrientationFilterWidget.vue create mode 100644 resources/vue/components/stock-images/Page.vue create mode 100644 resources/vue/components/stock-images/SearchWidget.vue create mode 100644 resources/vue/components/stock-images/SelectableImageCard.vue create mode 100644 resources/vue/components/stock-images/Selector.vue create mode 100644 resources/vue/components/stock-images/SelectorDialog.vue create mode 100644 resources/vue/components/stock-images/SelectorSearch.vue create mode 100644 resources/vue/components/stock-images/TagsInput.vue create mode 100644 resources/vue/components/stock-images/Thumbnail.vue create mode 100644 resources/vue/components/stock-images/ThumbnailCard.vue create mode 100644 resources/vue/components/stock-images/UploadBox.vue create mode 100644 resources/vue/components/stock-images/UploadDialog.vue create mode 100644 resources/vue/components/stock-images/colors.js create mode 100644 resources/vue/components/stock-images/components.js create mode 100644 resources/vue/components/stock-images/filters.js create mode 100644 resources/vue/components/stock-images/format.js create mode 100644 resources/vue/plugins/stock-images.js create mode 100644 resources/vue/store/stock-images.js diff --git a/app/controllers/stock_images.php b/app/controllers/stock_images.php new file mode 100644 index 00000000000..b272a71e9a7 --- /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 00000000000..d07c9d7be07 --- /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 d830e31f760..ced9f0dfffb 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 101d77035be..413e23c38f4 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 5119b01ee68..a6d76227b28 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 00000000000..e1a7b04338e --- /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 00000000000..7a2600a1c12 --- /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 f01a0965170..9d94c190dd7 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 b8d2a0c132d..89549170191 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 3f85d9bb3cf..aaca497e797 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 febe3cc8480..6bf0e79b7af 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 00000000000..77851fd1235 --- /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 00000000000..5c11a9e0466 --- /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 00000000000..e0b5468e3aa --- /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 00000000000..8bd3516f313 --- /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 00000000000..52e79e16e7b --- /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 00000000000..cef9db5cac7 --- /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 00000000000..b05b370cab7 --- /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 00000000000..b0cf1c69bec --- /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 e5a2f678f5f..dd74bc9bc2a 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 00000000000..e28582c5c99 --- /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 00000000000..73386ef5607 --- /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 00000000000..32f6c7aa92d --- /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 1e759e478fc..5eab70554a6 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 aee01a2c3ca..d3c77fafe1e 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 00000000000..54d09921d0b --- /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 119de6a73d0..c206ef5daf6 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 0b087b56017..9a91eebb352 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 1e7acb61897..d0d17fc14f7 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 GIT binary patch literal 89 zcmeAS@N?(olHy`uVBq!ia0vp^A|T8GBp6maa=Hkl6g*uVLnJOI*BoPDRw@t^W)?FD kVUesqI5VHEbFzjA3xn-pW_Fjel1U&vp00i_>zopr02#j&CjbBd literal 0 HcmV?d00001 diff --git a/public/pictures/stock-images/.gitkeep b/public/pictures/stock-images/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/resources/assets/javascripts/bootstrap/stock-images.js b/resources/assets/javascripts/bootstrap/stock-images.js new file mode 100644 index 00000000000..6ed6edff485 --- /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 d40d4e02b7e..915d960659d 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 00000000000..330fbc90185 --- /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 00000000000..d4654faa9f4 --- /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 00000000000..1336e96a2f5 --- /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 02006841176..6571b3430e0 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 abce90bbce0..c993300ede2 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 cdac1eefeaf..9c9740db67f 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 255c7998080..b8ebf0fde56 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 7c27c53ffc7..39bc47a325b 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 edf15aabea7..3ee6ca2f3d2 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 4f49d968381..cfe9099500c 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 00000000000..565c709db18 --- /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 00000000000..a88501c13b7 --- /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 00000000000..b816bdb33a6 --- /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 00000000000..68c71e23310 --- /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 00000000000..7d2c0db9b73 --- /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 00000000000..1e1dda29f69 --- /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 00000000000..2cd1621a9e1 --- /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 00000000000..ac819de4c74 --- /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 00000000000..4cdc12ac4ec --- /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 00000000000..5ae4a2c4dee --- /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 00000000000..b7daddc75e9 --- /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 00000000000..2288c66bfcb --- /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 00000000000..777b0f93a1f --- /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 00000000000..4e34f9f6f3a --- /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 00000000000..c6c46f8f88d --- /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 00000000000..849e91541a8 --- /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 00000000000..7e7f1158776 --- /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 00000000000..350f2548e62 --- /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 00000000000..201a6043d79 --- /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 }} × {{ 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 00000000000..a0941dd3c15 --- /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 00000000000..d27e827510b --- /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 00000000000..4ba138cdea0 --- /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 00000000000..93c61d274bd --- /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 00000000000..55cf72697ba --- /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 00000000000..f6e5b03dd39 --- /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 073948565fe..a31686bceb4 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 de8a438e416..d0d86255f03 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 00000000000..d99dbb385af --- /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 0eb95fde43b..ac5be55bcd1 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 64e65ce8f5f..0f56e3e7af8 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 00000000000..4ba9a0bdef0 --- /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, +}; -- GitLab