diff --git a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php index 3f85d9bb3cf0a60ed90fbb8562190182e1af7c28..aaca497e79764394a6c1140264addd015df56fc0 100644 --- a/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php +++ b/lib/classes/JsonApi/Routes/Courseware/StructuralElementsImageUpload.php @@ -34,12 +34,13 @@ class StructuralElementsImageUpload extends NonJsonApiController $fileRef = $this->handleUpload($request, $publicFolder, $structuralElement); // remove existing image - if ($structuralElement->image) { + if (is_a($structuralElement->image, \FileRef::class)) { $structuralElement->image->getFileType()->delete(); } // refer to newly uploaded image $structuralElement->image_id = $fileRef->id; + $structuralElement->image_type = \FileRef::class; $structuralElement->store(); return $response->withStatus(201); diff --git a/resources/vue/components/SearchWithFilter.vue b/resources/vue/components/SearchWithFilter.vue index 983012ea4aded3c617c58493eb15971ad7aaafd2..a488203b82b8a2c816ee8098c64128b8845617b3 100644 --- a/resources/vue/components/SearchWithFilter.vue +++ b/resources/vue/components/SearchWithFilter.vue @@ -1,43 +1,43 @@ <template> - <form @submit.prevent="onSearch"> - - <slot name="filters"></slot> - - <input - class="search-bar-input" - type="text" - v-model="searchTerm" - @focus="onInputFocus" - :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 }" - > - <StudipIcon shape="filter" :role="showFilterPanel ? 'info_alt' : 'clickable'" :size="20" /> - </button> - - <button type="submit" :value="$gettext('Suchen')" aria-controls="search" class="submit-search"> - <StudipIcon shape="search" :size="20" /> - </button> - - <div class="filterpanel" ref="filterPanel" v-if="'TODO' || showFilterPanel"> + <div> + <form @submit.prevent="onSearch"> + <slot name="filters"></slot> + + <input + 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" + > + <StudipIcon shape="filter" :role="showFilterPanel ? 'info_alt' : 'clickable'" :size="20" /> + </button> + + <button type="submit" :value="$gettext('Suchen')" aria-controls="search" class="submit-search"> + <StudipIcon shape="search" :size="20" /> + </button> + </form> + <div class="filterpanel" ref="filterPanel" v-if="showFilterPanel"> <slot></slot> </div> - </form> + </div> </template> <script> @@ -70,21 +70,9 @@ export default { onSearch() { this.$emit('search', this.searchTerm); }, - onInputFocus() { - this.showFilterPanel = true; - const check = ({ target }) => { - const filterPanel = this.$refs.filterPanel?.$el; - if ( - filterPanel === target || - filterPanel?.contains(target) || - target.classList.contains('search-bar-input') - ) { - return; - } - this.showFilterPanel = false; - window.removeEventListener('click', check); - }; - window.addEventListener('click', check.bind(this)); + onToggleFilterPanel() { + console.debug('toggle filter panel', !this.showFilterPanel); + this.showFilterPanel = !this.showFilterPanel; }, }, mounted() { @@ -114,6 +102,11 @@ input { width: 100%; } +input.search-bar-input { + line-height: 1.5; + padding-block: 0.25em; +} + button { align-items: center; background-color: var(--content-color-20); @@ -141,14 +134,8 @@ button.search-bar-erase { background-color: var(--white); border: thin solid var(--content-color-40); box-sizing: border-box; - height: 12em; - margin: 0px; - margin-block-start: 45px; - max-width: calc(100% - 30px); + margin-block-start: 1rem; padding: 10px; - position: absolute; - width: 50em; - z-index: 1; } .filterpanel::before, diff --git a/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue b/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue index 32b261402091b762fd4246b20c8d9f7caf214d73..bff4d4da9f21e3007336d632757365e21c47b671 100644 --- a/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue +++ b/resources/vue/components/courseware/CoursewareShelfDialogAdd.vue @@ -27,19 +27,21 @@ {{ $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> - <label v-if="selectedStockImage"> + <template v-if="selectedStockImage"> <StockImageSelectableImageCard :stock-image="selectedStockImage" /> - <button class="button" type="button" @click="selectedStockImage = null"> - {{ $gettext('Entfernen') }} - </button> - </label> + <label> + <button class="button" type="button" @click="selectedStockImage = null"> + {{ $gettext('Bild entfernen') }} + </button> + </label> + </template> <label v-else> {{ $gettext('oder') }} <br> @@ -130,6 +132,7 @@ </template> <script> +import CoursewareCompanionBox from './CoursewareCompanionBox.vue'; import StockImageSelectableImageCard from '../stock-images/SelectableImageCard.vue'; import StockImageChooser from '../stock-images/ChooserDialog.vue'; import StudipSelect from './../StudipSelect.vue'; @@ -141,6 +144,7 @@ export default { name: 'courseware-shelf-dialog-add', mixins: [colorMixin], components: { + CoursewareCompanionBox, StockImageSelectableImageCard, StockImageChooser, StudipWizardDialog, @@ -221,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() { @@ -281,7 +286,9 @@ export default { } }, onSelectStockImage(stockImage) { - // TODO: remove file selected for upload + if (this.$refs?.upload_image) { + this.$refs.upload_image.value = null; + } this.selectedStockImage = stockImage; this.showStockImageChooser = false; }, diff --git a/resources/vue/components/courseware/CoursewareStructuralElement.vue b/resources/vue/components/courseware/CoursewareStructuralElement.vue index 222691b2e6b25fc28c184b5b0a2fff75f2d44199..4f8bb95efa076ca9f1ba6d8e989c541358806284 100644 --- a/resources/vue/components/courseware/CoursewareStructuralElement.vue +++ b/resources/vue/components/courseware/CoursewareStructuralElement.vue @@ -237,7 +237,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> @@ -303,22 +303,33 @@ </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> + <template v-if="hasImage"> + <img + :src="image" + class="cw-structural-element-image-preview" + :alt="$gettext('Vorschaubild')" + /> + <label> + <button class="button" @click="deleteImage" v-translate>Bild löschen</button> + </label> + </template> + <div v-if="uploadFileError" class="messagebox messagebox_error"> {{ uploadFileError }} </div> - <label v-if="!showPreviewImage"> - <translate>Bild hochladen</translate> - <input ref="upload_image" type="file" accept="image/*" @change="checkUploadFile" /> - </label> + + <template v-if="!hasImage"> + <label> + {{ $gettext('Bild hochladen') }} + <input ref="upload_image" type="file" accept="image/*" @change="checkUploadFile" /> + </label> + {{ $gettext('oder') }} + <br> + <button class="button" type="button" @click="showStockImageChooser = true"> + {{ $gettext('Aus dem Bilderpool auswählen') }} + </button> + <StockImageChooser v-if="showStockImageChooser" @close="showStockImageChooser = false" @select="onSelectStockImage" /> + </template> </form> </courseware-tab> <courseware-tab v-if="(inCourse && !isTask) || inContent" :name="textEdit.approval" :index="3"> @@ -686,6 +697,7 @@ import colorMixin from '@/vue/mixins/courseware/colors.js'; import CoursewareDateInput from './CoursewareDateInput.vue'; import { FocusTrap } from 'focus-trap-vue'; import IsoDate from './IsoDate.vue'; +import StockImageChooser from '../stock-images/ChooserDialog.vue'; import StudipDialog from '../StudipDialog.vue'; import draggable from 'vuedraggable'; import { mapActions, mapGetters } from 'vuex'; @@ -711,6 +723,7 @@ export default { CoursewareDateInput, FocusTrap, IsoDate, + StockImageChooser, StudipDialog, draggable, }, @@ -783,7 +796,9 @@ export default { deletingPreviewImage: false, processing: false, keyboardSelected: null, - assistiveLive: '' + assistiveLive: '', + showStockImageChooser: false, + selectedStockImage: null, }; }, @@ -910,11 +925,18 @@ export default { }, image() { + if (this.selectedStockImage) { + return this.selectedStockImage.attributes["download-urls"].small + } 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() { @@ -1234,6 +1256,7 @@ export default { uploadImageForStructuralElement: 'uploadImageForStructuralElement', deleteImageForStructuralElement: 'deleteImageForStructuralElement', companionSuccess: 'companionSuccess', + setStockImageForStructuralElement: 'setStockImageForStructuralElement', showElementEditDialog: 'showElementEditDialog', showElementAddDialog: 'showElementAddDialog', showElementExportDialog: 'showElementExportDialog', @@ -1369,24 +1392,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'] !== '') { @@ -1399,10 +1428,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(); @@ -1715,7 +1747,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.showStockImageChooser = false; + this.deletingPreviewImage = false; + }, }, created() { this.pluginManager.registerComponentsLocally(this); diff --git a/resources/vue/components/stock-images/AttributesFieldset.vue b/resources/vue/components/stock-images/AttributesFieldset.vue index ca161caf0b6306ff07f34f72219cb776afe9b955..ad3f50d9d5ccc1c2f0f1b31b72ad368e7df98fd5 100644 --- a/resources/vue/components/stock-images/AttributesFieldset.vue +++ b/resources/vue/components/stock-images/AttributesFieldset.vue @@ -1,27 +1,26 @@ <template> - <fieldset> - <legend>{{ $gettext('Attribute') }}</legend> - <label> - {{ $gettext('Titel') }}<span aria-hidden="true">*</span> + <div> + <label class="studiprequired"> + {{ $gettext('Titel') }}<span title="Dies ist ein Pflichtfeld" aria-hidden="true" class="asterisk">*</span> <input type="text" required v-model="metadata.title" /> </label> - <label> - {{ $gettext('Beschreibung') }}<span aria-hidden="true">*</span> + <label class="studiprequired"> + {{ $gettext('Beschreibung') }}<span title="Dies ist ein Pflichtfeld" aria-hidden="true" class="asterisk">*</span> <textarea required v-model="metadata.description" /> </label> - <label> - {{ $gettext('Erstellt durch') }}<span aria-hidden="true">*</span> + <label class="studiprequired"> + {{ $gettext('Erstellt durch') }}<span title="Dies ist ein Pflichtfeld" aria-hidden="true" class="asterisk">*</span> <input type="text" required v-model="metadata.author" /> </label> - <label> - {{ $gettext('Lizenz') }}<span aria-hidden="true">*</span> + <label class="studiprequired"> + {{ $gettext('Lizenz') }}<span title="Dies ist ein Pflichtfeld" aria-hidden="true" class="asterisk">*</span> <textarea required v-model="metadata.license" /> </label> <label> {{ $gettext('Tags') }} <TagsInput v-model="tags" :suggestions="suggestedTags" /> </label> - </fieldset> + </div> </template> <script> import TagsInput from './TagsInput.vue'; diff --git a/resources/vue/components/stock-images/Chooser.vue b/resources/vue/components/stock-images/Chooser.vue index 84b60cc86cdb185cc90503a4eb88bd3c50715dff..6e6ebc09c27bdfa5489059b157632d837fe18700 100644 --- a/resources/vue/components/stock-images/Chooser.vue +++ b/resources/vue/components/stock-images/Chooser.vue @@ -51,7 +51,7 @@ export default { data: () => ({ activeFilters: { colors: [], - orientation: 'any', + orientation: 'landscape', }, query: '', }), @@ -83,8 +83,6 @@ ul { justify-content: flex-start; align-items: center; list-style: none; - padding: 1rem; - - margin-block-start: 12em; + padding: 1rem 0; } </style> diff --git a/resources/vue/components/stock-images/ChooserDialog.vue b/resources/vue/components/stock-images/ChooserDialog.vue index 0b94f753688bb97f9269a382c7925dc3f4f33dd7..bd677571db2b4348ec1b75959be2e6bac7f84d6e 100644 --- a/resources/vue/components/stock-images/ChooserDialog.vue +++ b/resources/vue/components/stock-images/ChooserDialog.vue @@ -1,5 +1,11 @@ <template> - <studip-dialog :title="$gettext('Bild auswählen')" :closeText="$gettext('Schließen')" height="420" @close="onClose"> + <studip-dialog + width="880" + :title="$gettext('Bild auswählen')" + :closeText="$gettext('Schließen')" + height="420" + @close="onClose" + > <template v-slot:dialogContent> <Chooser :stock-images="stockImages" @select="onSelectImage" /> </template> @@ -12,10 +18,6 @@ import Chooser from './Chooser.vue'; export default { data: () => ({ - filters: { - orientation: 'any', - colors: [], - }, query: '', selectedImage: null, }), @@ -52,10 +54,10 @@ export default { }); }, onClose() { - this.$emit("close"); + this.$emit('close'); }, onSelectImage(stockImage) { - this.$emit("select", stockImage); + this.$emit('select', stockImage); }, }, mounted() { diff --git a/resources/vue/components/stock-images/ChooserSearch.vue b/resources/vue/components/stock-images/ChooserSearch.vue index 82a3e47c1927863167b5f0fbacf68caed9a9d55d..05202d907ef6507c842d7ce6b4fb4b431a586cc5 100644 --- a/resources/vue/components/stock-images/ChooserSearch.vue +++ b/resources/vue/components/stock-images/ChooserSearch.vue @@ -137,6 +137,7 @@ export default { <style scoped> .stock-images-filters-colors { display: flex; + flex-wrap: wrap; gap: 0.25em; } .stock-images-filters-colors input[type='checkbox'] { diff --git a/resources/vue/components/stock-images/ColorFilterWidget.vue b/resources/vue/components/stock-images/ColorFilterWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..14e87e76fc8fda4c88dc14158b7a611958814b14 --- /dev/null +++ b/resources/vue/components/stock-images/ColorFilterWidget.vue @@ -0,0 +1,74 @@ +<template> + <SidebarWidget :title="$gettext('Farbe')"> + <template #content> + <VueSelect multiple v-model="selectedColors" :options="mixinColors" @input="onVueSelectInput" label="name"> + <template #option="{ name, hex }"> + <b class="stock-images-filters-color-swatch" :style="`background-color: ${hex}`"></b> + {{ name }} + </template> + + <template #selected-option="{ name, hex }"> + <b class="stock-images-filters-color-swatch" :style="`background-color: ${hex}`"></b> + </template> + + <template #no-options>{{ $gettext('Keine Auswahlmöglichkeiten') }}</template> + </VueSelect> + </template> + </SidebarWidget> +</template> +<script> +import colorMixin from '@/vue/mixins/courseware/colors.js'; +import SidebarWidget from '../SidebarWidget.vue'; +import { orientations } from './filters.js'; +import VueSelect from 'vue-select'; +import 'vue-select/dist/vue-select.css'; + +export default { + model: { + prop: 'filters', + event: 'change', + }, + props: { + filters: { + type: Object, + required: true, + }, + }, + mixins: [colorMixin], + components: { + SidebarWidget, + VueSelect, + }, + data: () => ({ + selectedColors: [], + }), + methods: { + onVueSelectInput(selectedColors) { + const colors = selectedColors.map(({ hex }) => hex); + this.$emit('change', { ...this.filters, colors }); + }, + }, + mounted() { + this.selectedColors = this.mixinColors.filter(({ hex }) => this.filters.colors.includes(hex)); + }, + watch: { + filters: { + handler(newValue) { + this.selectedColors = this.mixinColors.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/ImagesList.vue b/resources/vue/components/stock-images/ImagesList.vue index d141f7137b1442840abfe2fe948374bc78c95d7b..597b9ef8d6e80c3405f6b1b1c8f1bc451853676f 100644 --- a/resources/vue/components/stock-images/ImagesList.vue +++ b/resources/vue/components/stock-images/ImagesList.vue @@ -147,4 +147,8 @@ table.default { 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 index 46feccf37234fbbb4a8c332c5720a5c5e12cb4d9..8ffd9ef813388df5a9843468ac7c943fd00971b2 100644 --- a/resources/vue/components/stock-images/ImagesListItem.vue +++ b/resources/vue/components/stock-images/ImagesListItem.vue @@ -128,10 +128,6 @@ tr > td:nth-child(3) img { vertical-align: middle; } -tr > td:nth-child(1n + 3) { - font-size: smaller; -} - .stock-image-author, .stock-image-tags { font-size: 0.8em; diff --git a/resources/vue/components/stock-images/NavigationWidget.vue b/resources/vue/components/stock-images/NavigationWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..177eefe07a08bc772c9042f96e26d75226bdd382 --- /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.base_url}/dispatch.php/contents/overview`; + }, + }, +}; +</script> diff --git a/resources/vue/components/stock-images/OrientationFilterWidget.vue b/resources/vue/components/stock-images/OrientationFilterWidget.vue new file mode 100644 index 0000000000000000000000000000000000000000..29ee57813c85b416116b6595e13dd52bcb8081ef --- /dev/null +++ b/resources/vue/components/stock-images/OrientationFilterWidget.vue @@ -0,0 +1,36 @@ +<template> + <SidebarWidget :title="$gettext('Seitenausrichtung')"> + <template #content> + <select v-model="filters.orientation" class="sidebar-selectlist"> + <option v-for="[value, orientation] in Object.entries(orientations)" :value="value"> + {{ orientation.text }} + </option> + </select> + </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: { + orientations() { + return orientations; + }, + }, +}; +</script> diff --git a/resources/vue/components/stock-images/Page.vue b/resources/vue/components/stock-images/Page.vue index 983392234c5547c05985bd08ba0f63f330a922f9..bd7da4c4c2c02f631449e1603937288983622460 100644 --- a/resources/vue/components/stock-images/Page.vue +++ b/resources/vue/components/stock-images/Page.vue @@ -1,6 +1,5 @@ <template> <div> - <ImagesFilters v-model="filters" /> <ImagesPagination :per-page="perPage" :stock-images="filteredStockImages" v-model="page"> <ImagesList :checked-images="checkedImages" @@ -14,7 +13,10 @@ /> </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 @@ -35,10 +37,12 @@ <script> import { mapActions, mapGetters } from 'vuex'; import ActionsWidget from './ActionsWidget.vue'; +import ColorFilterWidget from './ColorFilterWidget.vue'; import EditDialog from './EditDialog.vue'; -import ImagesFilters from './ImagesFilters.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 { orientations, similarColors } from './filters.js'; @@ -64,7 +68,17 @@ const search = (stockImages, query) => { const sort = (stockImages) => _.sortBy([...stockImages], 'attributes.title'); export default { - components: { ActionsWidget, EditDialog, ImagesFilters, ImagesList, ImagesPagination, SearchWidget, UploadDialog }, + components: { + ActionsWidget, + ColorFilterWidget, + EditDialog, + ImagesList, + ImagesPagination, + NavigationWidget, + OrientationFilterWidget, + SearchWidget, + UploadDialog, + }, data: () => ({ checkedImages: [], filters: { diff --git a/resources/vue/components/stock-images/SelectableImageCard.vue b/resources/vue/components/stock-images/SelectableImageCard.vue index 5ba4e9be3139bc6fa2fd5e165f51deec8a529dcd..ac157bc965335b300ea5b14e7fc447a672b26a5b 100644 --- a/resources/vue/components/stock-images/SelectableImageCard.vue +++ b/resources/vue/components/stock-images/SelectableImageCard.vue @@ -1,6 +1,7 @@ <template> - <div> - <Thumbnail :url="thumbnailUrl" contain class="stock-images-image-card__thumbnail" /> + <div class="stock-images-selectable-image"> + <Thumbnail :url="thumbnailUrl" contain class="stock-images-image-card__thumbnail" width="8rem" /> + <div>{{ stockImage.attributes?.title ?? '' }}</div> </div> </template> @@ -27,6 +28,19 @@ export default { </script> <style scoped> +.stock-images-selectable-image { + overflow: hidden; + position: relative; +} +.stock-images-selectable-image > :last-child { + position: absolute; + background: #ffffffa0; + bottom: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} .stock-images-image-card__thumbnail { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAAAAACo4kLRAAAAIElEQVQY02N8xgADInAWEwMWMFQEGX/BmW8GiZMoFAQAPcMDB0TNGWIAAAAASUVORK5CYII=); } diff --git a/resources/vue/components/stock-images/ThumbnailCard.vue b/resources/vue/components/stock-images/ThumbnailCard.vue index b5b211692cfc114f635d1337644efc0f8d932268..0f71322a017c91161d4851b32fdcf45e458c906b 100644 --- a/resources/vue/components/stock-images/ThumbnailCard.vue +++ b/resources/vue/components/stock-images/ThumbnailCard.vue @@ -77,9 +77,3 @@ export default { }, }; </script> - -<style> -.stock-images-thumbnail-card { - font-size: smaller; -} -</style> diff --git a/resources/vue/components/stock-images/UploadBox.vue b/resources/vue/components/stock-images/UploadBox.vue index 2ed4d19d58d5540de7e5762b1b9040331734b31c..cba1f1a45d777b95470a71a8f118f5c4be814c1c 100644 --- a/resources/vue/components/stock-images/UploadBox.vue +++ b/resources/vue/components/stock-images/UploadBox.vue @@ -88,7 +88,6 @@ span.or { } .upload-button-holder button { - font-size: 20px; margin: 0; padding: 1em; } diff --git a/resources/vue/components/stock-images/components.js b/resources/vue/components/stock-images/components.js index e09063cbe5d3001cb0121654f7f3c4ea8055cda7..2cd8d2fc2d21827d06a16c437c7da8314012bcc1 100644 --- a/resources/vue/components/stock-images/components.js +++ b/resources/vue/components/stock-images/components.js @@ -3,6 +3,7 @@ export { default as StockImagesAttributesFieldset } from './AttributesFieldset.v export { default as StockImagesChooser } from './Chooser.vue'; export { default as StockImagesChooserDialog } from './ChooserDialog.vue'; export { default as StockImagesChooserSearch } from './ChooserSearch.vue'; +export { default as StockImagesColorFilterWidget } from './ColorFilterWidget.vue'; export { default as StockImagesEditDialog } from './EditDialog.vue'; export { default as StockImagesSelectableImageCard } from './SelectableImageCard.vue'; export { default as StockImagesImagesFilters } from './ImagesFilters.vue'; @@ -10,6 +11,8 @@ 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 StockImagesTagsInput } from './TagsInput.vue'; diff --git a/resources/vue/courseware-index-app.js b/resources/vue/courseware-index-app.js index 337b56f83b11e8738e67a3f610affdb288e7c799..19f170735b0086c3b4d68f7357edbd13dfede438 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 = () => @@ -154,6 +155,8 @@ const mountApp = async (STUDIP, createApp, element) => { store, }); + Vue.use(StockImagesPlugin, { store }); + app.$mount(element); return app; diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index 5eb40a00855bb9764194d44e443ecb1fd04b3954..a8e6b71db96ba0da551736cd70ba367eba8710a8 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -996,6 +996,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);