From 330ae49230c365b1e6a5b148c82dab0c1f4c69dd Mon Sep 17 00:00:00 2001
From: Ron Lucke <lucke@elan-ev.de>
Date: Tue, 1 Oct 2024 13:43:23 +0000
Subject: [PATCH] =?UTF-8?q?Archiv=20Upload=20f=C3=BCr=20Bilder-Pool?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes #4056

Merge request studip/studip!2904
---
 lib/classes/JsonApi/RouteMap.php              |   1 +
 .../JsonApi/Routes/StockImages/Authority.php  |   6 +-
 .../Routes/StockImages/StockImagesDelete.php  |   2 +-
 .../Routes/StockImages/StockImagesUpdate.php  |   4 +-
 .../Routes/StockImages/StockImagesUpload.php  |  59 +-------
 .../StockImages/StockImagesZipUpload.php      | 126 ++++++++++++++++++
 .../Routes/StockImages/UploadHelpers.php      |  62 +++++++++
 .../components/stock-images/ActionsWidget.vue |   7 +
 .../vue/components/stock-images/Page.vue      |  83 +++++++++++-
 .../vue/components/stock-images/UploadBox.vue |  25 +++-
 .../components/stock-images/UploadDialog.vue  |   7 +-
 .../stock-images/ZipUploadDialog.vue          |  63 +++++++++
 resources/vue/store/stock-images.js           |   8 ++
 13 files changed, 386 insertions(+), 67 deletions(-)
 create mode 100644 lib/classes/JsonApi/Routes/StockImages/StockImagesZipUpload.php
 create mode 100644 lib/classes/JsonApi/Routes/StockImages/UploadHelpers.php
 create mode 100644 resources/vue/components/stock-images/ZipUploadDialog.vue

diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 4b61b3d2774..579b4e91f3e 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -649,6 +649,7 @@ class RouteMap
         $group->delete('/stock-images/{id}', Routes\StockImages\StockImagesDelete::class);
 
         $group->post('/stock-images/{id}/blob', Routes\StockImages\StockImagesUpload::class);
+        $group->post('/stock-images/zip', Routes\StockImages\StockImagesZipUpload::class);
     }
 
     private function addAuthenticatedAvatarRoutes(RouteCollectorProxy $group): void
diff --git a/lib/classes/JsonApi/Routes/StockImages/Authority.php b/lib/classes/JsonApi/Routes/StockImages/Authority.php
index 77851fd1235..8b4b5372579 100644
--- a/lib/classes/JsonApi/Routes/StockImages/Authority.php
+++ b/lib/classes/JsonApi/Routes/StockImages/Authority.php
@@ -34,7 +34,7 @@ class Authority
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
-    public static function canUpdateStockImage(User $user, StockImage $resource): bool
+    public static function canUpdateStockImage(User $user): bool
     {
         return self::canCreateStockImage($user);
     }
@@ -42,7 +42,7 @@ class Authority
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
-    public static function canUploadStockImage(User $user, StockImage $resource): bool
+    public static function canUploadStockImage(User $user): bool
     {
         return self::canCreateStockImage($user);
     }
@@ -50,7 +50,7 @@ class Authority
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
-    public static function canDeleteStockImage(User $user, StockImage $resource): bool
+    public static function canDeleteStockImage(User $user): bool
     {
         return self::canCreateStockImage($user);
     }
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php
index e0b5468e3aa..4487f4a472c 100644
--- a/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesDelete.php
@@ -22,7 +22,7 @@ class StockImagesDelete extends JsonApiController
             throw new RecordNotFoundException();
         }
 
-        if (!Authority::canDeleteStockImage($this->getUser($request), $resource)) {
+        if (!Authority::canDeleteStockImage($this->getUser($request))) {
             throw new AuthorizationFailedException();
         }
         $resource->delete();
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php
index e52370587a3..942f7fdb2ce 100644
--- a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpdate.php
@@ -30,9 +30,9 @@ class StockImagesUpdate extends JsonApiController
             throw new RecordNotFoundException();
         }
 
-        $json = $this->validate($request, $resource);
+        $json = $this->validate($request);
         $user = $this->getUser($request);
-        if (!Authority::canUpdateStockImage($user, $resource)) {
+        if (!Authority::canUpdateStockImage($user)) {
             throw new AuthorizationFailedException();
         }
         $resource = $this->updateResource($resource, $json);
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php
index c4231179496..6cda4665d03 100644
--- a/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesUpload.php
@@ -14,6 +14,7 @@ use Studip\StockImages\PaletteCreator;
 
 class StockImagesUpload extends NonJsonApiController
 {
+    use UploadHelpers;
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
@@ -24,7 +25,7 @@ class StockImagesUpload extends NonJsonApiController
             throw new RecordNotFoundException();
         }
 
-        if (!Authority::canUploadStockImage($this->getUser($request), $resource)) {
+        if (!Authority::canUploadStockImage($this->getUser($request))) {
             throw new AuthorizationFailedException();
         }
 
@@ -36,9 +37,9 @@ class StockImagesUpload extends NonJsonApiController
 
     private function handleUpload(Request $request, \StockImage $resource): void
     {
-        $uploadedFile = $this->getUploadedFile($request);
+        $uploadedFile = self::getUploadedFile($request);
         if (UPLOAD_ERR_OK !== $uploadedFile->getError()) {
-            $error = $this->getErrorString($uploadedFile->getError());
+            $error = self::getErrorString($uploadedFile->getError());
             throw new BadRequestException($error);
         }
 
@@ -58,58 +59,6 @@ class StockImagesUpload extends NonJsonApiController
         $resource->store();
     }
 
-    private function getUploadedFile(Request $request): UploadedFileInterface
-    {
-        $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<UploadedFileInterface> 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
      */
diff --git a/lib/classes/JsonApi/Routes/StockImages/StockImagesZipUpload.php b/lib/classes/JsonApi/Routes/StockImages/StockImagesZipUpload.php
new file mode 100644
index 00000000000..9cf24c79539
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/StockImagesZipUpload.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\NonJsonApiController;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Nyholm\Psr7\UploadedFile;
+use Studip\StockImages\Scaler;
+use Studip\StockImages\PaletteCreator;
+
+class StockImagesZipUpload extends NonJsonApiController
+{
+    use UploadHelpers;
+
+    public function __invoke(Request $request, Response $response, $args): Response
+    {
+        if (!Authority::canUploadStockImage($this->getUser($request))) {
+            throw new AuthorizationFailedException();
+        }
+        $image_count = $this->handleUpload($request);
+
+        $response = $response->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write(json_encode(['image-count' => $image_count]));
+
+        return $response;
+    }
+
+    private function handleUpload(Request $request): int
+    {
+        $uploadedFile = self::getUploadedFile($request);
+        if (UPLOAD_ERR_OK !== $uploadedFile->getError()) {
+            $error = self::getErrorString($uploadedFile->getError());
+            throw new BadRequestException($error);
+        }
+
+        $validateError = self::validate($uploadedFile);
+        if (!empty($validateError)) {
+            throw new BadRequestException($validateError);
+        }
+
+        $tmp_path = $GLOBALS['TMP_PATH'] . '/stock-images/';
+
+        if (!file_exists($tmp_path)) {
+            mkdir($tmp_path);
+        }
+        $zip_path = $tmp_path . 'archiv.zip';
+        $uploadedFile->moveTo($zip_path);
+        $zip = new \ZipArchive;
+        if ($zip->open($zip_path) === TRUE) {
+            $zip->extractTo($tmp_path);
+            $zip->close();
+        } else {
+            $this->cleanTmp($tmp_path);
+            throw new BadRequestException('Can not extract Zip file.');
+        }
+        $csv_file = file($tmp_path . 'meta.csv');
+        if (!$csv_file) {
+            $this->cleanTmp($tmp_path);
+            throw new BadRequestException('No meta.csv file provided.');
+        }
+        $rows = array_map(
+            fn($v) => str_getcsv($v, ';'),
+            $csv_file
+        );
+        $header = array_shift($rows);
+        $images = [];
+        foreach ($rows as $row) {
+            $images[] = array_combine($header, $row);
+        }
+
+        $image_counter = 0;
+        foreach ($images as $i => $meta) {
+            $filename = $meta['filename'];
+            if (!$filename) {
+                continue;
+            }
+            $filepath = $tmp_path . $filename;
+            $filesize = filesize($filepath);
+            $imagesize = getimagesize($filepath);
+
+            $image = \StockImage::create([
+                'title' => $meta['title'] ?? 'unknown',
+                'description' => $meta['description'] ?? '',
+                'license' => $meta['license'] ?? '',
+                'author' => $meta['author'] ?? '',
+                'height' => $imagesize[1],
+                'width' => $imagesize[0],
+                'mime_type' => $imagesize['mime'],
+                'size' => $filesize,
+                'tags' => json_encode(explode(',', $meta['tags'])),
+            ]);
+
+            copy($filepath, $image->getPath());
+            $scaler = new \Studip\StockImages\Scaler();
+            $scaler($image);
+            $paletteCreator = new \Studip\StockImages\PaletteCreator();
+            $paletteCreator($image);
+
+            $image_counter++;
+        }
+
+        $this->cleanTmp($tmp_path);
+
+        return $image_counter;
+    }
+
+    private function cleanTmp(string $tmp_path): void
+    {
+        array_map('unlink', glob("$tmp_path/*.*"));
+        rmdir($tmp_path);
+    }
+
+    /**
+     * @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, ['application/x-zip-compressed', ' application/x-zip', 'application/zip'])) {
+            return 'Unsupported archive type.';
+        }
+    }
+}
\ No newline at end of file
diff --git a/lib/classes/JsonApi/Routes/StockImages/UploadHelpers.php b/lib/classes/JsonApi/Routes/StockImages/UploadHelpers.php
new file mode 100644
index 00000000000..31e98dbe0fd
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/StockImages/UploadHelpers.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace JsonApi\Routes\StockImages;
+
+use JsonApi\Errors\BadRequestException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Nyholm\Psr7\UploadedFile;
+
+trait UploadHelpers
+{
+    protected static function getUploadedFile(Request $request): UploadedFile
+    {
+        $files = iterator_to_array(self::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
+     */
+    protected static function getUploadedFiles(Request $request): iterable
+    {
+        foreach ($request->getUploadedFiles() as $item) {
+            if (!is_array($item)) {
+                yield $item;
+                continue;
+            }
+            foreach ($item as $file) {
+                yield $file;
+            }
+        }
+    }
+
+    protected static 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] ?? '';
+    }
+}
\ No newline at end of file
diff --git a/resources/vue/components/stock-images/ActionsWidget.vue b/resources/vue/components/stock-images/ActionsWidget.vue
index 565c709db18..07751a3a547 100644
--- a/resources/vue/components/stock-images/ActionsWidget.vue
+++ b/resources/vue/components/stock-images/ActionsWidget.vue
@@ -6,6 +6,10 @@
                     <studip-icon shape="upload" class="widget-action-icon" />
                     <button @click="onUploadClick">{{ $gettext('Bild hinzufügen') }}</button>
                 </li>
+                <li>
+                    <studip-icon shape="import" class="widget-action-icon" />
+                    <button @click="onZipUploadClick">{{ $gettext('Bildersammlung importieren') }}</button>
+                </li>
             </ul>
         </template>
     </SidebarWidget>
@@ -21,6 +25,9 @@ export default {
         onUploadClick() {
             this.$emit('initiateUpload');
         },
+        onZipUploadClick() {
+            this.$emit('initiateZipUpload');
+        }
     },
 };
 </script>
diff --git a/resources/vue/components/stock-images/Page.vue b/resources/vue/components/stock-images/Page.vue
index 3d8ce54fc84..3f06042b54f 100644
--- a/resources/vue/components/stock-images/Page.vue
+++ b/resources/vue/components/stock-images/Page.vue
@@ -1,6 +1,14 @@
 <template>
-    <div>
-        <ImagesPagination :per-page="perPage" :stock-images="filteredStockImages" v-model="page">
+    <div class="stock-images-page">
+        <studip-message-box v-if="showZipUploadMessage" :type="zipUploadMessageType">
+            {{ zipUploadMessage }}
+        </studip-message-box>
+        <ImagesPagination
+            v-show="!showUploadIndicator"
+            :per-page="perPage"
+            :stock-images="filteredStockImages"
+            v-model="page"
+        >
             <ImagesList
                 :checked-images="checkedImages"
                 :page="page"
@@ -12,11 +20,17 @@
                 @select="onSelectImage"
             />
         </ImagesPagination>
+        <studip-progress-indicator
+            v-show="showUploadIndicator"
+            class="image-upload-indicator"
+            :description="$gettext('Bilder werden hochgeladen...')"
+        >
+        </studip-progress-indicator>
         <MountingPortal mountTo="#stock-images-widget" name="sidebar-stock-images">
             <SearchWidget :query="query" @search="onSearch" />
             <OrientationFilterWidget v-model="filters" />
             <ColorFilterWidget v-model="filters" />
-            <ActionsWidget @initiateUpload="onUploadDialogShow" />
+            <ActionsWidget @initiateUpload="onUploadDialogShow" @initiateZipUpload="onZipUploadDialogShow" />
         </MountingPortal>
         <EditDialog
             :stock-image="selectedImage"
@@ -30,6 +44,11 @@
             @confirm="onUploadDialogConfirm"
             @cancel="showUpload = false"
         />
+        <ZipUploadDialog
+            :show="showZipUpload"
+            @confirm="onZipUploadDialogConfirm"
+            @cancel="showZipUpload = false"
+        ></ZipUploadDialog>
     </div>
 </template>
 
@@ -43,7 +62,11 @@ import ImagesPagination from './ImagesPagination.vue';
 import OrientationFilterWidget from './OrientationFilterWidget.vue';
 import SearchWidget from './SearchWidget.vue';
 import UploadDialog from './UploadDialog.vue';
+import ZipUploadDialog from './ZipUploadDialog.vue';
+import StudipMessageBox from '../StudipMessageBox.vue';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
 import { searchFilterAndSortImages } from './filters.js';
+import { $gettext } from '../../../assets/javascripts/lib/gettext';
 
 export default {
     components: {
@@ -55,6 +78,9 @@ export default {
         OrientationFilterWidget,
         SearchWidget,
         UploadDialog,
+        ZipUploadDialog,
+        StudipMessageBox,
+        StudipProgressIndicator,
     },
     data: () => ({
         checkedImages: [],
@@ -67,6 +93,11 @@ export default {
         query: '',
         selectedImage: null,
         showUpload: false,
+        showZipUpload: false,
+        showZipUploadMessage: false,
+        zipUploadMessage: '',
+        zipUploadMessageType: 'success',
+        showUploadIndicator: false,
     }),
     computed: {
         ...mapGetters({
@@ -81,6 +112,7 @@ export default {
     methods: {
         ...mapActions({
             createStockImage: 'studip/stockImages/create',
+            createStockImagesFromZip: 'studip/stockImages/createFromZip',
             loadStockImages: 'stock-images/loadWhere',
             updateStockImage: 'studip/stockImages/update',
         }),
@@ -110,9 +142,44 @@ export default {
                     console.error('Could not create stock image', error);
                 });
         },
+        onZipUploadDialogConfirm({ file }) {
+            this.showZipUpload = false;
+            this.showUploadIndicator = true;
+            this.createStockImagesFromZip([file])
+                .then((resp) => {
+                    this.showUploadIndicator = false;
+                    this.showZipUploadMessage = true;
+                    this.zipUploadMessageType = 'success';
+                    this.zipUploadMessage = this.$gettextInterpolate(
+                        this.$ngettext(
+                            '%{length} Bild wurde hinzugefügt',
+                            '%{length} Bilder wurden hinzugefügt',
+                            resp.data['image-count']
+                        ),
+                        {
+                            length: resp.data['image-count'],
+                        }
+                    );
+                    this.$nextTick(() => {
+                        this.fetchStockImages();
+                    });
+                })
+                .catch((error) => {
+                    this.showUploadIndicator = false;
+                    this.showZipUploadMessage = true;
+                    this.zipUploadMessageType = 'error';
+                    this.zipUploadMessage = this.$gettext('Beim importieren der Bilder ist ein Fehler aufgetreten.');
+                    this.fetchStockImages();
+                });
+        },
         onUploadDialogShow() {
             this.showUpload = true;
         },
+        onZipUploadDialogShow() {
+            this.showZipUpload = true;
+            this.showZipUploadMessage = false;
+            this.zipUploadMessage = '';
+        },
         async fetchStockImages() {
             const loadLimit = 30;
             await this.loadPage(0, loadLimit);
@@ -152,3 +219,13 @@ export default {
     },
 };
 </script>
+<style lang="scss">
+.stock-images-page {
+    height: 100%;
+
+    .image-upload-indicator {
+        top: 40%;
+        position: relative;
+    }
+}
+</style>
diff --git a/resources/vue/components/stock-images/UploadBox.vue b/resources/vue/components/stock-images/UploadBox.vue
index 80de74695b6..7eeb8631eee 100644
--- a/resources/vue/components/stock-images/UploadBox.vue
+++ b/resources/vue/components/stock-images/UploadBox.vue
@@ -5,9 +5,9 @@
                 <div class="icon-upload">
                     <studip-icon shape="upload" :size="100" alt="" :role="dragging ? 'info_alt' : 'clickable' "/>
                 </div>
-                <strong>{{ $gettext('Bild auswählen oder per Drag & Drop hierher ziehen') }}</strong>
+                <strong>{{ text }}</strong>
                 <div class="upload-button-holder">
-                    <input type="file" name="file" tabindex="-1" accept="image/*" ref="upload"
+                    <input type="file" name="file" tabindex="-1" :accept="acceptedFileType" ref="upload"
                            @change="onUpload"
                            @dragenter="setDragging(true)"
                            @dragleave="setDragging(false)"
@@ -20,9 +20,30 @@
 
 <script>
 export default {
+    props: {
+        type: {
+            type: String,
+            required: true,
+            validator: (type) => {
+                return ['image', '.zip'].includes(type);
+            },
+        },
+        text: {
+            type: String,
+            required: true,
+        }
+    },
     data: () => ({
         dragging: false,
     }),
+    computed: {
+        acceptedFileType() {
+            if (this.type === 'image') {
+                return 'image/gif, image/jpeg, image/png, image/webp';
+            }
+            return this.type;
+        }
+    },
     methods: {
         onUpload() {
             const files = this.$refs.upload.files;
diff --git a/resources/vue/components/stock-images/UploadDialog.vue b/resources/vue/components/stock-images/UploadDialog.vue
index d27e827510b..51040b265c9 100644
--- a/resources/vue/components/stock-images/UploadDialog.vue
+++ b/resources/vue/components/stock-images/UploadDialog.vue
@@ -10,7 +10,12 @@
     >
         <template #dialogContent>
             <form id="stock-images-upload-form" class="default" @submit.prevent="onSubmit">
-                <UploadBox v-if="state === STATES.IDLE" @upload="onUpload" />
+                <UploadBox
+                    v-if="state === STATES.IDLE"
+                    type="image"
+                    :text="$gettext('Bild auswählen oder per Drag & Drop hierher ziehen')"
+                    @upload="onUpload"
+                />
                 <MetadataBox
                     v-if="state === STATES.UPLOADED"
                     :file="file"
diff --git a/resources/vue/components/stock-images/ZipUploadDialog.vue b/resources/vue/components/stock-images/ZipUploadDialog.vue
new file mode 100644
index 00000000000..e2818343f4f
--- /dev/null
+++ b/resources/vue/components/stock-images/ZipUploadDialog.vue
@@ -0,0 +1,63 @@
+<template>
+    <studip-dialog
+        v-if="show"
+        height="270"
+        width="540"
+        :title="$gettext('Bildersammlung importieren')"
+        @close="onCancel"
+        closeClass="cancel"
+        :closeText="$gettext('Abbrechen')"
+    >
+        <template #dialogContent>
+            <form id="stock-images-zip-upload-form" class="default" @submit.prevent="onSubmit">
+                <label>
+                    {{ $gettext('Bildersammlung') }}
+                    <input ref="upload_zip" type="file" accept=".zip" name="zip" class="cw-file-input" @change="checkUploadFile">
+                </label>
+            </form>
+        </template>
+        <template #dialogButtons>
+            <button form="stock-images-zip-upload-form" type="submit" class="button accept" :disabled="!hasFile">
+                {{ $gettext('Importieren') }}
+            </button>
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+export default {
+    name: 'ZipUploadDialog',
+    props: {
+        show: {
+            type: Boolean,
+            required: true,
+        },
+    },
+    data() {
+        return {
+            file: null,
+        };
+    },
+    computed: {
+        hasFile() {
+            return this.file !== null;
+        },
+    },
+    methods: {
+        onSubmit() {
+            this.$emit('confirm', { file: this.file });
+            this.file = null;
+        },
+        onCancel() {
+            this.$emit('cancel');
+        },
+        checkUploadFile() {
+            this.file = this.$refs?.upload_zip?.files[0];
+        }
+    },
+};
+</script>
+<style lang="scss">
+@import url('./../../../assets/stylesheets/scss/courseware/layouts/input-file.scss');
+
+</style>
diff --git a/resources/vue/store/stock-images.js b/resources/vue/store/stock-images.js
index 4ba9a0bdef0..77aad9bb419 100644
--- a/resources/vue/store/stock-images.js
+++ b/resources/vue/store/stock-images.js
@@ -42,6 +42,14 @@ const actions = {
         return dispatch('stock-images/loadById', created, { root: true });
     },
 
+    async createFromZip({ dispatch, rootGetters, state }, [file]) {
+        const formData = new FormData();
+        formData.append('zip', file);
+        const resp =  await state.httpClient.post(`stock-images/zip`, formData);
+
+        return resp;
+    },
+
     async update({ dispatch, rootGetters, state }, { stockImage, attributes }) {
         console.debug('stockImage', stockImage);
         stockImage.attributes = { ...stockImage.attributes, ...attributes };
-- 
GitLab