diff options
| author | Philipp Schüttlöffel <schuettloeffel@zqs.uni-hannover.de> | 2024-09-24 10:53:31 +0200 |
|---|---|---|
| committer | Philipp Schüttlöffel <schuettloeffel@zqs.uni-hannover.de> | 2024-09-24 10:53:31 +0200 |
| commit | 4459dd7917f4d1c34f40bb68f0e991e9c3d53e4c (patch) | |
| tree | 5c07151ae61276d334e88f6309c30d439a85c12e /resources/vue/components | |
| parent | da0022e5c1abbf9825ae76debaabdff7e8623bb4 (diff) | |
| parent | 97a188592c679890a25c37ab78463add76a52ff7 (diff) | |
Merge branch 'main' into issue-3911issue-3911
Diffstat (limited to 'resources/vue/components')
67 files changed, 2026 insertions, 1037 deletions
diff --git a/resources/vue/components/ActiveFilter.vue b/resources/vue/components/ActiveFilter.vue index d4654fa..5394bce 100644 --- a/resources/vue/components/ActiveFilter.vue +++ b/resources/vue/components/ActiveFilter.vue @@ -4,7 +4,7 @@ <button @click="onRemoveActiveFilter" type="button" - :title="$gettextInterpolate($gettext('Filter \'%{name}\' entfernen'), { name })" + :title="$gettextInterpolate($gettext('Filter \'%{name}\' entfernen'), { name }, true)" > <StudipIcon class="text-bottom" shape="decline" role="presentation" alt="" /> </button> diff --git a/resources/vue/components/AdminCourses.vue b/resources/vue/components/AdminCourses.vue index a2f61d7..d24a900 100644 --- a/resources/vue/components/AdminCourses.vue +++ b/resources/vue/components/AdminCourses.vue @@ -23,7 +23,7 @@ <th v-for="activeField in sortedActivatedFields" :key="`field-${activeField}`" :class="sort.by === activeField ? 'sort' + sort.direction.toLowerCase() : ''"> <a href="#" @click.prevent="changeSort(activeField)" - :title="sort.by === activeField && sort.direction === 'ASC' ? $gettextInterpolate('Sortiert aufsteigend nach %{field}', {field: fields[activeField]}) : (sort.by === activeField && sort.direction === 'DESC' ? $gettextInterpolate('Sortiert absteigend nach %{ field } ', { field: fields[activeField]}) : $gettextInterpolate('Sortieren nach %{ field }', { field: fields[activeField]}))" + :title="sort.by === activeField && sort.direction === 'ASC' ? $gettextInterpolate('Sortiert aufsteigend nach %{field}', {field: fields[activeField]}, true) : (sort.by === activeField && sort.direction === 'DESC' ? $gettextInterpolate('Sortiert absteigend nach %{ field } ', { field: fields[activeField]}, true) : $gettextInterpolate('Sortieren nach %{ field }', { field: fields[activeField]}, true))" v-if="!unsortableFields.includes(activeField)" > {{ fields[activeField] }} @@ -221,22 +221,28 @@ export default { }); }, sortArray (array) { + const mappedFields = { + last_activity: 'last_activity_raw', + semester: 'semester_sort', + }; + if (!array.length) { return []; } - let sortby = this.sort.by; - if (!this.activatedFields.includes(sortby) && sortby !== 'completion') { + if (!this.activatedFields.includes(this.sort.by) && this.sort.by !== 'completion') { return array; } const striptags = function (text) { - if (typeof text === "string") { + if (typeof text === 'string') { return text.replace(/(<([^>]+)>)/gi, ""); } else { return text; } }; + let sortby = mappedFields[this.sort.by] ?? this.sort.by; + // Define sort direction by this factor const directionFactor = this.sort.direction === 'ASC' ? 1 : -1; @@ -246,33 +252,28 @@ export default { sensitivity: 'base' }); let sortFunction = function (a, b) { - return collator.compare(striptags(a[sortby]), striptags(b[sortby])); + return collator.compare(striptags(a[sortby]), striptags(b[sortby])) + || collator.compare(striptags(a.number), striptags(b.number)); }; - if (sortby === 'last_activity') { - sortFunction = (a, b) => a.last_activity_raw - b.last_activity_raw; - } else if (sortby === 'name') { - sortFunction = (a, b) => { - return collator.compare(striptags(a.name), striptags(b.name)) - || collator.compare(striptags(a.number), striptags(b.number)); - }; - } else if (sortby === 'number') { + if (sortby === 'number') { sortFunction = (a, b) => { return collator.compare(striptags(a.number), striptags(b.number)) || collator.compare(striptags(a.name), striptags(b.name)); }; } else { - let is_numeric = true; - for (let i in array) { - if (striptags(array[i][sortby]) && isNaN(striptags(array[i][sortby]))) { - is_numeric = false; - break; - } - } + let is_numeric = !array.some(i => { + const value = striptags(i[sortby]); + return value && isNaN(parseInt(value, 10)); + }); + if (is_numeric) { sortFunction = function (a, b) { - return (striptags(a[sortby]) ? parseInt(striptags(a[sortby]), 10) : 0) - - (striptags(b[sortby]) ? parseInt(striptags(b[sortby]), 10) : 0); + const aValue = (striptags(a[sortby]) ? parseInt(striptags(a[sortby]), 10) : 0); + const bValue = (striptags(b[sortby]) ? parseInt(striptags(b[sortby]), 10) : 0); + + return aValue - bValue + || collator.compare(striptags(a.number), striptags(b.number)); }; } } diff --git a/resources/vue/components/CacheAdministration.vue b/resources/vue/components/CacheAdministration.vue index af3d461..5da9568 100644 --- a/resources/vue/components/CacheAdministration.vue +++ b/resources/vue/components/CacheAdministration.vue @@ -82,24 +82,24 @@ export default { * @param event */ getCacheConfig (event) { - fetch(STUDIP.URLHelper.getURL(`dispatch.php/admin/cache/get_config/${this.selectedCacheType}`)) - .then((response) => { - if (!response.ok) { - throw response - } + const url = STUDIP.URLHelper.getURL( + 'dispatch.php/admin/cache/get_config', + {cache: this.selectedCacheType}, + true + ); + fetch(url).then((response) => { + if (!response.ok) { + throw response + } - response.json() - .then((json) => { - this.configComponent = json.component - this.configProps = json.props - }).catch((error) => { - console.error(error) - console.error(error.status + ': ', error.statusText) - }) - }).catch((error) => { - console.error(error) - console.error(error.status + ': ', error.statusText) - }) + response.json().then((json) => { + this.configComponent = json.component + this.configProps = json.props + }); + }).catch((error) => { + console.error(error) + console.error(error.status + ': ', error.statusText) + }) }, validateConfig () { if (this.configComponent == null || this.isValid) { diff --git a/resources/vue/components/ConsultationCreator.vue b/resources/vue/components/ConsultationCreator.vue new file mode 100644 index 0000000..375e98a --- /dev/null +++ b/resources/vue/components/ConsultationCreator.vue @@ -0,0 +1,496 @@ +<template> + <form :action="storeUrl" method="post" class="default" :data-dialog="asDialog ? '' : null" @submit="validateInputs"> + <input type="hidden" :name="csrf.name" :value="csrf.value"> + <input v-for="id in responsibleGroups" type="hidden" name="responsibilities[statusgroup][]" :value="id" :key="`group-${id}`"> + <input v-for="id in responsibleInstitutes" type="hidden" name="responsibilities[institute][]" :value="id" :key="`institute-${id}`"> + <input v-for="id in responsibleUsers" type="hidden" name="responsibilities[user][]" :value="id" :key="`user-${id}`"> + + <StudipMessageBox type="info" v-if="errors.length > 0"> + {{ $gettext('Folgende Angaben müssen korrigiert werden, um das Formular abschicken zu können:') }} + + <template #details> + <ul> + <li v-for="(error, index) in errors" :key="`error-${index}`"> + {{ error }} + </li> + </ul> + </template> + </StudipMessageBox> + + <fieldset> + <legend>{{ $gettext('Ort und Zeit') }}</legend> + + <label> + <span class="required">{{ $gettext('Ort') }}</span> + + <input required type="text" name="room" + v-model="room" + :placeholder="$gettext('Ort')"> + </label> + + <label :class="{'col-3': !isSingleDay}"> + <span class="required">{{ $gettext('Intervall') }}</span> + <select required name="interval" v-model="interval"> + <option v-for="(label, value) in intervals" :key="value" :value="value"> + {{ label }} + </option> + </select> + </label> + + <label class="col-3" v-if="!isSingleDay"> + <span class="required">{{ $gettext('Am Wochentag') }}</span> + + <select required name="day-of-week" v-model="dayOfWeek"> + <option v-for="(label, value) in daysOfTheWeek" :value="value" :key="value"> + {{ label }} + </option> + </select> + </label> + + <label :class="{'col-3': !isSingleDay}"> + <span class="required">{{ isSingleDay ? $gettext('Datum') : $gettext('Beginn') }}</span> + + <Datepicker v-model="startDate" + name="start-date" + :disable-holidays="true" + :placeholder="$gettext('tt.mm.jjjj')" + mindate="today" + :emit-date="true" + ></Datepicker> + </label> + + <label class="col-3" v-if="!isSingleDay"> + <span class="required">{{ $gettext('Ende') }}</span> + + <Datepicker v-model="endDate" + name="end-date" + :disable-holidays="true" + :placeholder="$gettext('tt.mm.jjjj')" + :mindate="startDate" + :emit-date="true" + ></Datepicker> + </label> + + <label for="start-time" class="col-3"> + <span class="required">{{ $gettext('Von') }}</span> + + <Timepicker name="start-time" + v-model="startTime" + :maxtime="endTime" + ></Timepicker> + </label> + + <label for="ende_hour" class="col-3"> + <span class="required">{{ $gettext('Bis') }}</span> + + <Timepicker name="end-time" + v-model="endTime" + :mintime="startTime" + ></Timepicker> + </label> + + <label class="col-3"> + <span class="required">{{ $gettext('Dauer eines Termins in Minuten') }}</span> + <input required type="number" name="duration" min="1" + v-model="duration"> + </label> + + <label class="col-3"> + {{ $gettext('Maximale Teilnehmerzahl') }} + <StudipTooltipIcon :text="$gettext('Falls Sie mehrere Personen zulassen wollen (wie z.B. zu einer Klausureinsicht), so geben Sie hier die maximale Anzahl an Personen an, die sich anmelden dürfen.')"></StudipTooltipIcon> + <input required type="text" name="size" id="size" min="1" max="50" + v-model="size"> + </label> + + <label> + <input type="checkbox" name="pause" value="1" + v-model="pause"> + {{ $gettext('Pausen zwischen den Terminen einfügen?') }} + </label> + + <label class="col-3" v-if="pause"> + {{ $gettext('Eine Pause nach wie vielen Minuten einfügen?') }} + <input type="number" name="pause_time" min="1" + v-model="pauseTime"> + </label> + + <label class="col-3" v-if="pause"> + {{ $gettext('Dauer der Pause in Minuten') }} + <input type="number" name="pause_duration" min="1" + v-model="pauseDuration"> + </label> + + <label> + <input type="checkbox" name="lock" value="1" + v-model="lock"> + {{ $gettext('Termine für Buchungen sperren?') }} + </label> + + <label v-if="lock"> + {{ $gettext('Wieviele Stunden vor Beginn des Blocks sollen die Termine für Buchungen gesperrt werden?') }} + <input type="number" name="lock_time" min="1" + v-model="lockTime"> + </label> + + <label> + <input type="checkbox" name="consecutive" value="1" + v-model="consecutive"> + {{ $gettext('Termine innerhalb der Blöcke nur fortlaufend vergeben') }} + </label> + + <slot name="extension-point-1"></slot> + </fieldset> + + <fieldset v-if="withResponsible"> + <legend>{{ $gettext('Durchführende Personen, Gruppen oder Einrichtungen') }}</legend> + + <template v-if="isInstitute"> + <p> + {{ $gettext('Bei Einrichtungen muss mindestens eine durchführende Person, Gruppe oder Einrichtung zugewiesen werden.') }} + </p> + <p> + {{ $gettext('Bitte beachten Sie, dass bei Zuweisungen von Statusgruppen alle Personen der Gruppe mit dem Status ' + + '"tutor" und "dozent" als durchführende Personen zugewiesen werden und über alle Buchungen ' + + 'informiert werden.') }} + {{ $gettext('Gleiches gilt für eine zugewiesene Einrichtung. Bitte achten Sie darauf, dass Sie Ihre hier ' + + ' getroffene Auswahl in Absprache tätigen.') }} + </p> + </template> + + <label v-if="withResponsible.users"> + {{ $gettext('Durchführende Personen') }} + <StudipSelect v-model="responsibleUsers" + :options="withResponsible.users" + :reduce="option => option.id" + multiple + :clearable="true" + > + <template #open-indicator> + <span><studip-icon shape="arr_1down" :size="10" /></span> + </template> + </StudipSelect> + </label> + + <label v-if="withResponsible.groups"> + {{ $gettext('Durchführende Gruppen') }} + <StudipSelect v-model="responsibleGroups" + :options="withResponsible.groups" + :reduce="option => option.id" + multiple + :clearable="true" + > + <template #open-indicator> + <span><studip-icon shape="arr_1down" :size="10" /></span> + </template> + </StudipSelect> + </label> + + <label v-if="withResponsible.institutes"> + {{ $gettext('Durchführende Einrichtungen') }} + <StudipSelect v-model="responsibleInstitutes" + :options="withResponsible.institutes" + :reduce="option => option.id" + multiple + :clearable="true" + > + <template #open-indicator> + <span><studip-icon shape="arr_1down" :size="10" /></span> + </template> + </StudipSelect> + </label> + </fieldset> + + <fieldset> + <legend>{{ $gettext('Weitere Einstellungen') }}</legend> + + <label> + {{ $gettext('Information zu den Terminen in diesem Block') }} + <textarea name="note" v-model="note"></textarea> + </label> + + <label> + <input type="checkbox" name="calender-events" value="1" + v-model="calendarEvents"> + {{ $gettext('Die freien Termine auch im Kalender markieren') }} + </label> + + <label v-if="isCourse"> + <input type="checkbox" name="mail-to-tutors" value="1" + v-model="mailToTutors"> + {{ $gettext('Tutor/innen beim Versand von Buchungsbenachrichtigungen berücksichtigen?') }} + </label> + + <label> + <input type="checkbox" name="show-participants" value="1" + v-model="showParticipants"> + {{ $gettext('Namen der buchenden Personen sind öffentlich sichtbar') }} + </label> + + <label>{{ $gettext('Grund der Buchung abfragen') }}</label> + <div class="hgroup"> + <label> + <input type="radio" name="require-reason" value="yes" + v-model="requireReason"> + {{ $gettext('Ja, zwingend erforderlich') }} + </label> + + <label> + <input type="radio" name="require-reason" value="optional" + v-model="requireReason"> + {{ $gettext('Ja, optional') }} + </label> + + <label> + <input type="radio" name="require-reason" value="no" + v-model="requireReason"> + {{ $gettext('Nein') }} + </label> + </div> + + <label> + {{ $gettext('Bestätigung für folgenden Text einholen') }} + ({{ $gettext('optional') }}) + <StudipTooltipIcon :text="$gettext('Wird hier ein Text eingegeben, so müssen Buchende bestätigen, dass sie diesen Text gelesen haben.')"></StudipTooltipIcon> + <textarea name="confirmation-text" v-model="confirmationText"></textarea> + </label> + + <slot name="extension-point-2"></slot> + </fieldset> + + <fieldset v-if="needsConfirmation"> + <legend>{{ $gettext('Bestätigung der Erstellung vieler Termine') }}</legend> + + <p> + {{ $gettext('Sie erstellen eine sehr große Anzahl an Terminen.') }} + {{ $gettext('Bitte bestätigen Sie diese Aktion.') }} + </p> + + <label> + <input type="checkbox" v-model="confirmed"> + {{ $gettextInterpolate( + $gettext('Ja, ich möchte wirklich %{ n } Termine erstellen.'), + { n: slotCount } + ) }} + </label> + </fieldset> + + <footer data-dialog-button> + <button class="accept button" :disabled="!confirmed"> + {{ $gettext('Termin speichern') }} + </button> + <a :href="cancelUrl" class="cancel button" @click="evt => closeCreator(evt)"> + {{ $gettext('Abbrechen') }} + </a> + </footer> + </form> +</template> +<script> +import StudipTooltipIcon from './StudipTooltipIcon.vue'; +import Datepicker from './Datepicker.vue'; + +import moment from 'moment'; +import StudipSelect from './StudipSelect.vue'; +import Timepicker from './Timepicker.vue'; + +export default { + name: 'ConsultationCreator', + components: {Datepicker, StudipSelect, StudipTooltipIcon, Timepicker}, + props: { + asDialog: { + type: Boolean, + default: false, + }, + cancelUrl: { + type: String, + required: true + }, + defaultRoom: String, + rangeType: { + type: String, + required: true, + }, + slotCountThreshold: { + type: Number, + required: true, + }, + storeUrl: { + type: String, + required: true + }, + withResponsible: { + type: [Boolean, Object], + default: false, + }, + }, + data() { + return { + calendarEvents: false, + confirmationText: '', + confirmed: false, + consecutive: false, + dayOfWeek: (new Date()).getDay(), + duration: 15, + endDate: moment().add(4, 'weeks').toDate(), + endTime: '09:00', + errors: [], + interval: 1, + lock: false, + lockTime: 24, + mailToTutors: true, + note: '', + pause: false, + pauseDuration: 15, + pauseTime: 45, + requireReason: 'optional', + responsibleGroups: [], + responsibleInstitutes: [], + responsibleUsers: [], + room: this.defaultRoom, + showParticipants: false, + size: 1, + + slotCount: null, + startDate: moment().add(1, 'weeks').toDate(), + startTime: '08:00', + } + }, + computed: { + csrf() { + return STUDIP.CSRF_TOKEN; + }, + daysOfTheWeek() { + return { + 1: this.$gettext('Montag'), + 2: this.$gettext('Dienstag'), + 3: this.$gettext('Mittwoch'), + 4: this.$gettext('Donnerstag'), + 5: this.$gettext('Freitag'), + 6: this.$gettext('Samstag'), + 0: this.$gettext('Sonntag'), + }; + }, + intervals() { + return { + 0: this.$gettext('einmalig (ohne Wiederholung)'), + 1: this.$gettext('wöchentlich'), + 2: this.$gettext('zweiwöchentlich'), + 3: this.$gettext('dreiwöchentlich'), + 4: this.$gettext('monatlich'), + }; + }, + isCourse() { + return this.rangeType === 'Course'; + }, + isInstitute() { + return this.rangeType === 'Institute'; + }, + isSingleDay() { + return this.interval === '0'; + }, + needsConfirmation() { + return this.slotCount > this.slotCountThreshold; + }, + recalculationProperty() { + return [ + this.startDate, + this.startTime, + this.endDate, + this.endTime, + this.dayOfWeek, + this.interval, + this.duration, + this.pause, + this.pauseTime, + this.pauseDuration, + ].join(); + }, + }, + methods: { + closeCreator(event) { + if (this.$el.closest('.studip-dialog')) { + STUDIP.Dialog.close(); + event.preventDefault(); + } + }, + validateInputs(event) { + const errors = []; + + if (this.startTime > this.endTime) { + errors.push(this.$gettext('Die Endzeit liegt vor der Startzeit!')); + } + + if (this.startDate > this.endDate) { + errors.push(this.$gettext('Das Enddatum liegt vor dem Startdatum!')); + } + + if (this.pauseTime && this.pauseTime < this.duration) { + errors.push(this.$gettext('Die definierte Zeit bis zur Pause ist kleiner als die Dauer eines Termins.')); + } + + if ( + this.isInstitute + && this.responsibleGroups.length === 0 + && this.responsibleInstitutes.length === 0 + && this.responsibleUsers.length === 0 + ) { + errors.push(this.$gettext('Es muss mindestens eine durchführende Person, Statusgruppe oder Einrichtung ausgewählt werden.')); + } + + if (this.needsConfirmation && !this.confirmed) { + errors.push(this.$gettext('Sie müssen bestätigen, dass sie eine große Anzahl von Terminen erstellen möchten.')); + + } + + if (errors.length > 0) { + this.errors = errors; + event.preventDefault(); + } + }, + combineDateAndTime(date, time) { + const [hour, minute] = time.split(':').map(item => parseInt(item, 10)); + const result = new Date(date); + result.setHours(hour); + result.setMinutes(minute); + result.setSeconds(0); + return result; + } + }, + watch: { + interval(current) { + if (current === '0') { + this.endDate = new Date(this.startDate); + } + }, + recalculationProperty: { + handler() { + STUDIP.jsonapi.withPromises().GET('consultation-slots/count', { + data: { + start: this.combineDateAndTime(this.startDate, this.startTime).toISOString(), + end: this.combineDateAndTime(this.endDate, this.endTime).toISOString(), + dow: this.dayOfWeek, + interval: this.interval, + duration: this.duration, + pause_time: this.pause ? this.pauseTime : null, + pause_duration: this.pause ? this.pauseDuration : null, + } + }).then((count) => { + this.slotCount = count; + this.confirmed = count <= this.slotCountThreshold; + }); + }, + immediate: true + }, + startDate(current) { + this.dayOfWeek = current.getDay(); + }, + }, + beforeCreate() { + STUDIP.Vue.emit('ConsultationCreatorWillCreate', this); + } +} +</script> +<style scoped> +form.default label input[type="time"] { + max-width: 48em; +} +</style> diff --git a/resources/vue/components/ContentModulesControl.vue b/resources/vue/components/ContentModulesControl.vue index f403bdd..34f4125 100644 --- a/resources/vue/components/ContentModulesControl.vue +++ b/resources/vue/components/ContentModulesControl.vue @@ -15,7 +15,7 @@ @click.prevent="toggleModuleVisibility(module)"> <studip-icon :shape="module.visibility !== 'tutor' ? 'visibility-visible' : 'visibility-invisible'" class="text-bottom" - :title="$gettextInterpolate($gettext('Inhaltsmodul %{ name } für Teilnehmende unsichtbar bzw. sichtbar schalten'), { name: module.displayname})"></studip-icon> + :title="$gettextInterpolate($gettext('Inhaltsmodul %{ name } für Teilnehmende unsichtbar bzw. sichtbar schalten'), { name: module.displayname}, true)"></studip-icon> </a> </div> </div> diff --git a/resources/vue/components/ContentModulesEditTiles.vue b/resources/vue/components/ContentModulesEditTiles.vue index 383c6ab..7407632 100644 --- a/resources/vue/components/ContentModulesEditTiles.vue +++ b/resources/vue/components/ContentModulesEditTiles.vue @@ -35,7 +35,8 @@ $gettext( 'Sortierelement für Werkzeug %{module}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.' ), - { module: module.displayname } + { module: module.displayname }, + true ) " @keydown="keyboardHandler($event, module)" @@ -76,7 +77,8 @@ $gettext( 'Inhaltsmodul %{ name } für Teilnehmende unsichtbar bzw. sichtbar schalten' ), - { name: module.displayname } + { name: module.displayname }, + true ) " ></studip-icon> @@ -90,7 +92,8 @@ $gettext( 'Umbenennen des Inhaltsmoduls %{ name }' ), - { name: module.displayname } + { name: module.displayname }, + true ) " ></studip-icon> diff --git a/resources/vue/components/ContentmodulesEditTable.vue b/resources/vue/components/ContentmodulesEditTable.vue index 8724a24..0a8a0aa 100644 --- a/resources/vue/components/ContentmodulesEditTable.vue +++ b/resources/vue/components/ContentmodulesEditTable.vue @@ -25,7 +25,8 @@ $gettext( 'Sortierelement für Module %{module}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.' ), - { module: module.displayname } + { module: module.displayname }, + true ) " @keydown="keyboardHandler($event, module)" @@ -71,7 +72,8 @@ $gettext( 'Inhaltsmodul %{ name } für Teilnehmende unsichtbar bzw. sichtbar schalten' ), - { name: module.displayname } + { name: module.displayname }, + true ) " ></studip-icon> @@ -83,7 +85,7 @@ :title=" $gettextInterpolate($gettext('Umbenennen des Inhaltsmoduls %{ name }'), { name: module.displayname, - }) + }, true) " ></studip-icon> </a> diff --git a/resources/vue/components/Datepicker.vue b/resources/vue/components/Datepicker.vue index 3db44ce..5c2c0f7 100644 --- a/resources/vue/components/Datepicker.vue +++ b/resources/vue/components/Datepicker.vue @@ -1,75 +1,143 @@ <template> <span> - <input type="hidden" :name="name" :value="value"> + <input type="hidden" :name="name" :value="returnValue"> <input type="text" ref="visibleInput" class="visible_input" - @change="setUnixTimestamp" v-bind="$attrs" - v-on="$listeners"> + v-on="$listeners" + :placeholder="placeholder"> </span> </template> <script> +import RestrictedDatesHelper from '../../assets/javascripts/lib/RestrictedDatesHelper'; + export default { - name: "datepicker", + name: 'Datepicker', inheritAttrs: false, props: { name: { type: String, required: false }, - value: { - required: false + value: [Date, String, Number], + mindate: [Date, Number, String], + maxdate: [Date, Number, String], + placeholder: String, + disableHolidays: { + type: Boolean, + default: false, }, - mindate: { - required: false + emitDate: { + type: Boolean, + default: false, }, - maxdate: { - required: false + returnAs: { + type: String, + default: 'localized', + validator(value) { + return ['localized', 'unix', 'iso'].includes(value); + } + } + }, + computed: { + input() { + return $(this.$refs.visibleInput); + }, + parameters() { + let params = { + onSelect: () => { + this.setUnixTimestamp(); + }, + maxDate: this.convertInputToNativeDate(this.maxdate), + minDate: this.convertInputToNativeDate(this.mindate), + }; + if (this.disableHolidays) { + params.beforeShowDay = (date) => { + RestrictedDatesHelper.loadRestrictedDatesByYear(date.getFullYear()).then( + () => this.input.datepicker('refresh'), + () => null + ); + + const {reason, lock} = RestrictedDatesHelper.isDateRestricted(date); + return [!lock, lock ? 'ui-datepicker-is-locked' : null, reason]; + }; + } + + return params; + }, + returnValue() { + if (this.returnAs === 'unix') { + return this.convertInputToUnixTimestamp(this.value); + } + + if (this.returnAs === 'iso') { + return this.convertInputToNativeDate(this.value).toISOString(); + } + + return this.convertInputToNativeDate(this.value).toLocaleDateString(); } }, methods: { + convertInputToNativeDate(input) { + if (input instanceof Date) { + return input; + } + + if (input === 'today') { + return new Date(); + } + + return input ? new Date(input * 1000) : null; + }, + convertInputToUnixTimestamp(input) { + if (input instanceof Date) { + return Math.floor(input.getTime() / 1000); + } + + if (!isNaN(parseInt(input, 10))) { + return parseInt(input, 10); + } + + return input; + }, setUnixTimestamp () { - let formatted_date = this.$refs.visibleInput.value; - let date = formatted_date.match(/(\d+)/g); - date = new Date(`${date[2]}-${date[1]}-${date[0]} ${date[3]}:${date[4]}`); - this.$emit('input', Math.floor(date / 1000)); + let date = this.input.datepicker('getDate'); + this.$emit('input', this.emitDate ? date : Math.floor(date.getTime() / 1000)); } }, mounted () { - let value = !isNaN(parseInt(this.value, 10)) ? parseInt(this.value, 10) : this.value; + let value = this.convertInputToUnixTimestamp(this.value); + if (Number.isInteger(value)) { let date = new Date(value * 1000); - let formatted_date = - (date.getDate() < 10 ? "0" : "") + date.getDate() - + "." - + (date.getMonth() < 9 ? "0" : "") + (date.getMonth() + 1) - + "." - + date.getFullYear(); - this.$refs.visibleInput.value = formatted_date; + this.input.val(date.toLocaleDateString()); } else { - this.$refs.visibleInput.value = value; - } - let params = { - onSelect: () => { - this.setUnixTimestamp(); - } - }; - if (this.mindate) { - params.minDate = new Date(this.mindate * 1000) - } - if (this.maxdate) { - params.maxDate = new Date(this.maxdate * 1000) + this.input.val(value); } - $(this.$refs.visibleInput).datetimepicker(params); + this.input.datepicker(this.parameters); }, watch: { - mindate (new_data, old_data) { - $(this.$refs.visibleInput).datetimepicker('option', 'minDate', new Date(new_data * 1000)); + maxdate(current) { + this.input.datepicker( + 'option', + 'maxDate', + this.convertInputToNativeDate(current) + ); }, - maxdate (new_data, old_data) { - $(this.$refs.visibleInput).datetimepicker('option', 'maxDate', new Date(new_data * 1000)); + mindate(current) { + this.input.datepicker( + 'option', + 'minDate', + this.convertInputToNativeDate(current) + ); + }, + value(current, previous) { + if (current.toISOString() !== previous.toISOString()) { + this.input.datepicker('setDate', current); + this.input.datepicker('refresh'); + } } } } diff --git a/resources/vue/components/EditableList.vue b/resources/vue/components/EditableList.vue index cf1716b..39c32ac 100644 --- a/resources/vue/components/EditableList.vue +++ b/resources/vue/components/EditableList.vue @@ -12,7 +12,7 @@ <studip-icon v-if="item.icon" :shape="item.icon" role="info" :size="20" class="text-bottom" alt=""></studip-icon> <input v-if="name" type="hidden" :name="name + '[]'" :value="item.value"> <span>{{item.name}}</span> - <button v-if="item.deletable" @click.prevent="deleteItem(item)" :title="$gettextInterpolate($gettext('%{ name } löschen'), {name: item.name})" class="undecorated"> + <button v-if="item.deletable" @click.prevent="deleteItem(item)" :title="$gettextInterpolate($gettext('%{ name } löschen'), {name: item.name}, true)" class="undecorated"> <studip-icon shape="trash" role="clickable" :size="20" class="text-bottom"></studip-icon> </button> </li> diff --git a/resources/vue/components/FilesTable.vue b/resources/vue/components/FilesTable.vue index 646e914..1e19cc9 100644 --- a/resources/vue/components/FilesTable.vue +++ b/resources/vue/components/FilesTable.vue @@ -42,40 +42,83 @@ </colgroup> <thead> <tr class="sortable"> - <th v-if="show_bulk_actions" data-sort="false" :aria-label="$gettext('Ordner und Dateien auswählen')"> + <th v-if="show_bulk_actions" + data-sort="false" + :aria-label="$gettext('Ordner und Dateien auswählen')"> <studip-proxy-checkbox v-model="selectedIds" :total="allIds" :title="$gettext('Alle Ordner und Dateien auswählen')" ></studip-proxy-checkbox> </th> - <th @click="sort('mime_type')" :class="sortClasses('mime_type')"> - <a href="#" @click.prevent> + <th @click="sort('mime_type')" + :class="sortClasses('mime_type')" + :aria-sort="getAriaSortString('mime_type')" + :aria-label="getAriaSortLabel('mime_type', $gettext('Typ'))" + > + <a href="#" + @click.prevent + :title="$gettext('Nach Typ sortieren')"> {{ $gettext('Typ') }} </a> </th> - <th @click="sort('name')" :class="sortClasses('name')"> - <a href="#" @click.prevent> + <th @click="sort('name')" + :class="sortClasses('name')" + :aria-sort="getAriaSortString('name')" + :aria-label="getAriaSortLabel('name', $gettext('Name'))" + > + <a href="#" + @click.prevent + :title="$gettext('Nach Name sortieren')"> {{ $gettext('Name') }} </a> </th> - <th @click="sort('size')" class="responsive-hidden" :class="sortClasses('size')"> - <a href="#" @click.prevent> + <th @click="sort('size')" + class="responsive-hidden" + :class="sortClasses('size')" + :aria-sort="getAriaSortString('size')" + :aria-label="getAriaSortLabel('size', $gettext('Größe'))" + > + <a href="#" + @click.prevent + :title="$gettext('Nach Größe sortieren')"> {{ $gettext('Größe') }} </a> </th> - <th v-if="showdownloads" @click="sort('downloads')" class="responsive-hidden" :class="sortClasses('downloads')"> - <a href="#" @click.prevent> + <th v-if="showdownloads" + @click="sort('downloads')" + class="responsive-hidden" + :class="sortClasses('downloads')" + :aria-sort="getAriaSortString('downloads')" + :aria-label="getAriaSortLabel('downloads', $gettext('Downloads'))" + > + <a href="#" + @click.prevent + :title="$gettext('Nach Downloads sortieren')"> {{ $gettext('Downloads') }} </a> </th> - <th class="responsive-hidden" @click="sort('author_name')" :class="sortClasses('author_name')"> - <a href="#" @click.prevent> + <th class="responsive-hidden" + @click="sort('author_name')" + :class="sortClasses('author_name')" + :aria-sort="getAriaSortString('author_name')" + :aria-label="getAriaSortLabel('author_name', $gettext('Autor/-in'))" + > + <a href="#" + @click.prevent + :title="$gettext('Nach Autor/-in sortieren')"> {{ $gettext('Autor/-in') }} </a> </th> - <th class="responsive-hidden" @click="sort('chdate')" :class="sortClasses('chdate')"> - <a href="#" @click.prevent> + <th class="responsive-hidden" + @click="sort('chdate')" + :class="sortClasses('chdate')" + :aria-sort="getAriaSortString('chdate')" + :aria-label="getAriaSortLabel('chdate', $gettext('Datum'))" + > + <a href="#" + @click.prevent + :title="$gettext('Nach Datum sortieren')"> {{ $gettext('Datum') }} </a> </th> @@ -83,11 +126,15 @@ :key="index" @click="sort(index)" class="responsive-hidden" - :class="sortClasses(index)"> - <a href="#" @click.prevent> + :class="sortClasses(index)" + :aria-sort="getAriaSortString(name)" + :aria-label="getAriaSortLabel(name, name)" + > + <a href="#" + @click.prevent + :title="$gettextInterpolate($gettext('Nach %{ colName } sortieren'), {colName: name}, true)"> {{name}} </a> - </th> <th class="actions" data-sort="false">{{ $gettext('Aktionen') }}</th> </tr> @@ -120,12 +167,17 @@ ></studip-proxied-checkbox> </td> <td class="document-icon"> - <a :href="folder.url" :id="`folder-${folder.id}`"> - <studip-icon :shape="folder.icon" :size="26" class="text-bottom"></studip-icon> + <a :href="folder.url" + :id="`folder-${folder.id}`" + :title="$gettextInterpolate($gettext('Ordner %{foldername} öffnen'), + { foldername: folder.name}, true)"> + <studip-icon :shape="folder.icon" :size="26" class="text-bottom" alt=""></studip-icon> </a> </td> <td :class="{'filter-match': valueMatchesFilter(folder.name)}"> - <a :href="folder.url"> + <a :href="folder.url" + :title="$gettextInterpolate($gettext('Ordner %{foldername} öffnen'), + { foldername: folder.name}, true)"> <span v-html="highlightString(folder.name)"></span> </a> </td> @@ -172,7 +224,11 @@ ></studip-proxied-checkbox> </td> <td class="document-icon"> - <a v-if="file.download_url" :href="file.download_url" target="_blank" rel="noopener noreferrer"> + <a v-if="file.download_url" + :href="file.download_url" + target="_blank" rel="noopener noreferrer" + :title="$gettextInterpolate($gettext('Datei %{filename} herunterladen'), + { filename: file.name }, true)"> <studip-icon :shape="file.icon" :size="24" class="text-bottom"></studip-icon> </a> <studip-icon v-else :shape="file.icon" :size="24"></studip-icon> @@ -180,10 +236,16 @@ <a :href="file.download_url" v-if="file.download_url && file.mime_type.indexOf('image/') === 0" class="lightbox-image" - data-lightbox="gallery"></a> + data-lightbox="gallery" + :title="$gettextInterpolate($gettext('Datei %{filename} anzeigen'), + { filename: file.name }, true)"></a> </td> <td :class="{'filter-match': valueMatchesFilter(file.name)}"> - <a :href="file.details_url" data-dialog :id="`file-${file.id}`"> + <a :href="file.details_url" + data-dialog + :id="`file-${file.id}`" + :title="$gettextInterpolate($gettext('Details zur Datei %{filename} anzeigen'), + { filename: file.name }, true)"> <span v-html="highlightString(file.name)"></span> <studip-icon v-if="file.isAccessible" shape="accessibility" @@ -405,6 +467,20 @@ export default { this.$gettext('Datei %{name} auswählen'), {name: file.name} ); + }, + getAriaSortString(column) { + return column === this.sortedBy + ? (this.sortDirection === 'asc' ? 'ascending' : 'descending') + : null; + }, + getAriaSortLabel(column, label) { + if (column !== this.sortedBy) { + return null; + } + const template = this.sortDirection === 'asc' + ? this.$gettext('Es wird aufsteigend nach der Spalte %{ label } sortiert.') + : this.$gettext('Es wird absteigend nach der Spalte %{ label } sortiert.'); + return this.$gettextInterpolate(template, { label }); } }, computed: { diff --git a/resources/vue/components/MyCoursesTables.vue b/resources/vue/components/MyCoursesTables.vue index 80d04dd..1c39d95 100644 --- a/resources/vue/components/MyCoursesTables.vue +++ b/resources/vue/components/MyCoursesTables.vue @@ -12,7 +12,11 @@ </colgroup> <thead> <tr class="sortable"> - <th></th> + <th> + <span class="sr-only"> + {{ $gettext('Zugeordnete Farbgruppe') }} + </span> + </th> <th></th> <th v-if="getConfig('sem_number') && !responsiveDisplay" :class="getOrderClasses('number')"> <a href="#" @click.prevent="changeOrder('number')"> @@ -37,7 +41,14 @@ <th v-if="!responsiveDisplay" class="dont-hide" colspan="2"></th> </tr> <tr v-for="course in getOrderedCourses(subgroup.ids)" :data-course-id="course.id" :class="getCourseClasses(course)" :key="course.id"> - <td :class="`gruppe${course.group}`"></td> + <td :class="`gruppe${course.group}`"> + <span class="sr-only"> + {{ $gettextInterpolate( + $gettext('Diese Veranstaltung gehört zur Farbgruppe %{group}'), + course + ) }} + </span> + </td> <td :class="{'subcourse-indented': isChild(course)}"> <span :style="{backgroundImage: `url(${course.avatar}`}" class="my-courses-avatar course-avatar-small" :title="course.name" alt=""></span> </td> diff --git a/resources/vue/components/MyCoursesTiles.vue b/resources/vue/components/MyCoursesTiles.vue index 73f8aad..12b2627 100644 --- a/resources/vue/components/MyCoursesTiles.vue +++ b/resources/vue/components/MyCoursesTiles.vue @@ -136,7 +136,7 @@ export default { return this.shownColorPicker === course.id; }, changeColor(course, index) { - STUDIP.jsonapi.PATCH(`course-memberships/${course.id}_${this.userid}`, { + STUDIP.jsonapi.withPromises().patch(`course-memberships/${course.id}_${this.userid}`, { data: { data: { type: 'course-memberships', @@ -145,9 +145,9 @@ export default { } } } - }).done(() => { + }).then(() => { course.group = index; - }).always(() => { + }).finally(() => { this.shownColorPicker = null; }); }, diff --git a/resources/vue/components/Quicksearch.vue b/resources/vue/components/Quicksearch.vue index 94b0a3a..0f37ae0 100644 --- a/resources/vue/components/Quicksearch.vue +++ b/resources/vue/components/Quicksearch.vue @@ -119,6 +119,7 @@ export default { this.results = []; this.$emit('input', this.returnValue, this.inputValue); + this.inputValue = ''; }, selectUp () { if (this.selected > 0) { diff --git a/resources/vue/components/StudipAssetImg.vue b/resources/vue/components/StudipAssetImg.vue index 6a250a9..b60915b 100644 --- a/resources/vue/components/StudipAssetImg.vue +++ b/resources/vue/components/StudipAssetImg.vue @@ -1,6 +1,6 @@ <template> <img :src="url" - :width="width"> + :width="width" alt=""> </template> <script> diff --git a/resources/vue/components/StudipDialog.vue b/resources/vue/components/StudipDialog.vue index f14dd37..79337f8 100644 --- a/resources/vue/components/StudipDialog.vue +++ b/resources/vue/components/StudipDialog.vue @@ -1,6 +1,6 @@ <template> <MountingPortal mountTo="body" append> - <focus-trap v-model="trap" :initial-focus="() => defaultFocus ? $refs.buttonB : null"> + <focus-trap v-model="trap"> <div class="studip-dialog" @keydown.esc="closeDialog"> <transition name="dialog-fade"> <div class="studip-dialog-backdrop"> @@ -38,7 +38,11 @@ <header class="studip-dialog-header" > - <span :id="dialogTitleId" class="studip-dialog-title" :title="dialogTitle"> + <span :id="dialogTitleId" + class="studip-dialog-title" + :title="dialogTitle" + role="heading" + aria-level="2"> {{ dialogTitle }} </span> <slot name="dialogHeader"></slot> @@ -262,5 +266,13 @@ export default { return typeof value !== "number" ? 0 : value; } }, + mounted() { + if (this.defaultFocus) { + this.$nextTick() + .then(() => { + this.$refs.buttonB.focus(); + }); + } + } }; </script> diff --git a/resources/vue/components/StudipFileChooser.vue b/resources/vue/components/StudipFileChooser.vue index e021aec..798d646 100644 --- a/resources/vue/components/StudipFileChooser.vue +++ b/resources/vue/components/StudipFileChooser.vue @@ -128,7 +128,7 @@ export default { max-width: 48em; button { margin: 0.5ex 0 0.5ex 0; - min-width: 140px; + width: 150px; } span { box-sizing: border-box; @@ -138,7 +138,7 @@ export default { font-size: 14px; line-height: 130%; min-width: 100px; - width: calc(100% - 140px); + width: calc(100% - 150px); overflow: hidden; text-overflow: ellipsis; padding: 5px 15px; diff --git a/resources/vue/components/StudipIcon.vue b/resources/vue/components/StudipIcon.vue index 9ca1c7b..5e27372 100644 --- a/resources/vue/components/StudipIcon.vue +++ b/resources/vue/components/StudipIcon.vue @@ -9,6 +9,7 @@ :role="ariaRole" v-bind="$attrs" v-on="$listeners" + :alt="$attrs.alt ?? ''" /> <img v-else :src="url" @@ -17,6 +18,7 @@ :role="ariaRole" v-bind="$attrs" v-on="$listeners" + :alt="$attrs.alt ?? ''" /> </template> diff --git a/resources/vue/components/StudipTooltipIcon.vue b/resources/vue/components/StudipTooltipIcon.vue index 39856bb..30d2033 100644 --- a/resources/vue/components/StudipTooltipIcon.vue +++ b/resources/vue/components/StudipTooltipIcon.vue @@ -37,6 +37,9 @@ </script> <style lang="scss" scoped> +.tooltip img { + vertical-align: text-bottom; +} .tooltip.tooltip-icon::before { display: none; } diff --git a/resources/vue/components/StudipWysiwyg.vue b/resources/vue/components/StudipWysiwyg.vue index e343e80..799c5f1 100644 --- a/resources/vue/components/StudipWysiwyg.vue +++ b/resources/vue/components/StudipWysiwyg.vue @@ -2,7 +2,7 @@ <ckeditor :editor="editor" :config="editorConfig" - @ready="prefill" + @ready="onReady" v-model="currentText" @input="onInput" /> @@ -29,11 +29,15 @@ export default { }, default: 'classic', }, + autofocus: Boolean, }, data() { return { currentText: '', editorConfig: {}, + + createdEditor: null, + shouldFocus: this.autofocus, }; }, computed: { @@ -48,13 +52,25 @@ export default { }, }, methods: { - prefill(editor) { + onReady(editor) { + this.createdEditor = editor; this.currentText = this.text; + + if (this.shouldFocus) { + this.focus(); + } }, onInput(value) { this.currentText = value; this.$emit('input', value); }, + focus() { + if (this.createdEditor) { + this.createdEditor.focus(); + } else { + this.shouldFocus = true; + } + } }, created() { STUDIP.loadChunk('mathjax'); diff --git a/resources/vue/components/SystemNotification.vue b/resources/vue/components/SystemNotification.vue new file mode 100644 index 0000000..60cefd7 --- /dev/null +++ b/resources/vue/components/SystemNotification.vue @@ -0,0 +1,173 @@ +<template> + <div v-cloak + class="system-notification" + :class="cssClasses" + @mouseover="disruptTimeout" + @mouseout="initTimeout" + @focus="disruptTimeout" + @blur="initTimeout" + > + <div class="system-notification-icon"> + <studip-icon :shape="icon.shape" + :size="48" + :role="icon.color" + alt="" + title=""></studip-icon> + </div> + <div class="system-notification-content"> + <p v-html="notification.message"></p> + <p class="sr-only" v-if="hasTimeout"> + {{ $gettext('Strg+Alt+T hält das automatische Ausblenden der Meldung an bzw. setzt es wieder fort.') }} + </p> + <details v-if="notification.details?.length > 0" + class="system-notification-details"> + <summary> + {{ $gettext('Details') }} + </summary> + <template v-if="Array.isArray(notification.details)"> + <p v-for="(detail, index) in notification.details" + :key="index" + v-html="detail"></p> + </template> + <p v-else v-html="notification.details"></p> + </details> + </div> + <button v-if="allowClosing" + class="system-notification-close undecorated" + :title="$gettext('Diese Meldung schließen')" + @click.prevent="destroyMe" + @keydown.space="destroyMe" + tabindex="0"> + <studip-icon shape="decline" + :size="20" + class="close-system-notification"/> + </button> + <transition v-if="hasTimeout" + name="system-notification-timeout" + appear + > + <div v-if="!stopTimeout" + class="system-notification-timeout" + ref="timeout-counter"></div> + </transition> + </div> +</template> + +<script> +export default { + name: 'SystemNotification', + props: { + allowClosing: { + type: Boolean, + default: true + }, + appendTo: { + type: String, + default: null + }, + notification: { + type: Object, + required: true + }, + visibleFor: { + type: Number, + default: 5000 + } + }, + data() { + return { + stopTimeout: false, + timeout: null, + windowIsBlurred: false, + } + }, + computed: { + cssClasses() { + const classes = [`system-notification-${this.notification.type}`]; + if (this.isDisrupted) { + classes.push('system-notification-disrupted'); + } + return classes; + }, + hasTimeout() { + return !['exception', 'error'].includes(this.notification.type); + }, + icon() { + let iconShape = 'info-circle'; + let iconColor = 'info'; + switch (this.type) { + case 'exception': + iconShape = 'exclaim-circle'; + iconColor = 'info_alt'; + break; + case 'error': + iconShape = 'exclaim-circle'; + iconColor = 'status-red'; + break; + case 'warning': + iconShape = 'exclaim-circle'; + iconColor = 'status-yellow'; + break; + case 'success': + iconShape = 'check-circle'; + iconColor = 'status-green'; + break; + } + return {shape: iconShape, color: iconColor}; + }, + isDisrupted() { + return this.timeout !== null && this.stopTimeout; + } + }, + methods: { + destroyMe() { + this.$emit('destroyMe'); + }, + disruptTimeout() { + this.stopTimeout = true; + clearTimeout(this.timeout); + }, + initTimeout() { + if (this.hasTimeout && this.visibleFor > 0) { + this.stopTimeout = false; + this.timeout = setTimeout( + () => this.destroyMe(), + this.visibleFor + ); + } + } + }, + mounted() { + if (this.appendTo !== null) { + const target = document.querySelector(this.appendTo); + + // Create a live area for screen reader compatibility. + const div = document.createElement('div'); + div.setAttribute('role', 'alert'); + div.appendChild(this.$el); + if (target) { + target.prepend(div); + } + } + + this.initTimeout(); + + this.globalOn('disrupt-system-notifications', this.disruptTimeout); + this.globalOn('resume-system-notifications', this.initTimeout); + + if (!STUDIP.config?.PERSONAL_NOTIFICATIONS_AUDIO_DEACTIVATED) { + const audio = new Audio(STUDIP.ASSETS_URL + '/sounds/blubb.mp3'); + audio.play(); + } + }, + destroyed() { + this.globalOff('disrupt-system-notifications', this.disruptTimeout); + this.globalOff('resume-system-notifications', this.initTimeout); + } +} +</script> +<style scoped> +[v-cloak] { + display: none; +} +</style> diff --git a/resources/vue/components/SystemNotificationManager.vue b/resources/vue/components/SystemNotificationManager.vue new file mode 100644 index 0000000..0acef24 --- /dev/null +++ b/resources/vue/components/SystemNotificationManager.vue @@ -0,0 +1,73 @@ +<template> + <transition-group name="system-notification-slide" + :class="'system-notifications ' + (placement === 'topcenter' ? 'top-center' : 'bottom-right')" + tag="div" + role="alert" + appear + > + <system-notification v-for="notification in allNotifications" + :key="`message-${notification.key}`" + :notification="notification" + @destroyMe="destroyNotification(notification)" + ></system-notification> + </transition-group> +</template> + +<script> +import SystemNotification from './SystemNotification.vue'; + +export default { + name: 'SystemNotificationManager', + components: { SystemNotification }, + props: { + appendAllTo: String, + notifications: { + type: [Array, Object], + default: () => [] + }, + placement: { + type: String, + default: 'topcenter', + validator: value => { + return ['topcenter', 'bottomright'].includes(value); + } + } + }, + data() { + return { + allNotifications: [], + counter: 0, + stoppedNotifications: false + } + }, + methods: { + destroyNotification(notification) { + this.allNotifications = this.allNotifications.filter(n => n !== notification); + } + }, + created() { + if (Array.isArray(this.notifications)) { + this.allNotifications = [...this.notifications]; + } else { + this.allNotifications = Object.values(this.notifications); + } + }, + mounted() { + this.globalOn('push-system-notification', notification => { + this.allNotifications.push({ + key: this.counter++, + ...notification + }); + }); + + window.addEventListener('keydown', evt => { + if (evt.altKey && evt.ctrlKey && evt.code === 'KeyT') { + this.stoppedNotifications = !this.stoppedNotifications; + + const event = this.stoppedNotifications ? 'disrupt-system-notifications' : 'resume-system-notifications'; + this.globalEmit(event); + } + }); + } +} +</script> diff --git a/resources/vue/components/Timepicker.vue b/resources/vue/components/Timepicker.vue new file mode 100644 index 0000000..e0b0feb --- /dev/null +++ b/resources/vue/components/Timepicker.vue @@ -0,0 +1,37 @@ +<template> + <input type="time" + ref="visibleInput" + class="hasTimepicker" + v-model="timeValue" + :placeholder="placeholder" + :min="mintime" + :max="maxtime" + :name="name"> +</template> + +<script> +export default { + name: 'Timepicker', + inheritAttrs: false, + props: { + name: { + type: String, + required: false + }, + value: String, + mintime: String, + maxtime: String, + placeholder: String, + }, + computed: { + timeValue: { + get() { + return this.value; + }, + set(value) { + this.$emit('input', value); + } + } + } +} +</script> diff --git a/resources/vue/components/blubber/Composer.vue b/resources/vue/components/blubber/Composer.vue index f4b6aff..72a5d1c 100644 --- a/resources/vue/components/blubber/Composer.vue +++ b/resources/vue/components/blubber/Composer.vue @@ -1,6 +1,9 @@ <template> <div class="writer" :style="composerStyle"> <studip-icon shape="blubber" :size="30" role="info"></studip-icon> + <label for="blubber-placeholder" class="sr-only"> + {{ placeholder || $gettext('Schreib was, frag was. Enter zum Abschicken.') }} + </label> <textarea :placeholder="placeholder || $gettext('Schreib was, frag was. Enter zum Abschicken.')" v-model="localText" @@ -10,6 +13,7 @@ @keyup.up.exact="editPreviousComment" @keyup="saveCommentToSession" ref="textarea" + id="blubber-placeholder" ></textarea> <a class="send" @click="submit" :title="$gettext('Abschicken')"> <studip-icon shape="arr_2up" :size="30"></studip-icon> diff --git a/resources/vue/components/courseware/CoursewareContentPermissions.vue b/resources/vue/components/courseware/CoursewareContentPermissions.vue index d2412ef..60f0964 100644 --- a/resources/vue/components/courseware/CoursewareContentPermissions.vue +++ b/resources/vue/components/courseware/CoursewareContentPermissions.vue @@ -42,7 +42,7 @@ <td class="perm"> <input class="right" - :title="$gettextInterpolate($gettext('Leserechte für %{ userName }'), { userName: user_perm.username })" + :title="$gettextInterpolate($gettext('Leserechte für %{ userName }'), { userName: user_perm.username }, true)" type="radio" :name="`${user_perm.id}_right`" value="read" @@ -53,7 +53,7 @@ <td class="perm"> <input class="right" - :title="$gettextInterpolate($gettext('Lese- und Schreibrechte für %{ userName }'), { userName: user_perm.username })" + :title="$gettextInterpolate($gettext('Lese- und Schreibrechte für %{ userName }'), { userName: user_perm.username }, true)" type="radio" :name="`${user_perm.id}_right`" value="write" @@ -75,7 +75,7 @@ <td class="actions"> <button class="cw-permission-delete" - :title="$gettextInterpolate($gettext('Entfernen der Rechte von %{ userName }'), { userName: user_perm.username })" + :title="$gettextInterpolate($gettext('Entfernen der Rechte von %{ userName }'), { userName: user_perm.username }, true)" @click.prevent="confirmDeleteUserPerm(index)" > </button> diff --git a/resources/vue/components/courseware/blocks/CoursewareBiographyCareerBlock.vue b/resources/vue/components/courseware/blocks/CoursewareBiographyCareerBlock.vue index c366618..98fab71 100644 --- a/resources/vue/components/courseware/blocks/CoursewareBiographyCareerBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareBiographyCareerBlock.vue @@ -17,8 +17,8 @@ class="cw-timeline-item" > <div class="cw-timeline-item-icon cw-timeline-item-icon-color-studip-blue"> - <studip-icon v-if="item.type === 'school'" shape="doctoral-cap" role="clickable" size="32"/> - <studip-icon v-if="item.type === 'experience'" shape="tools" role="clickable" size="32"/> + <studip-icon v-if="item.type === 'school'" shape="doctoral-cap" role="clickable" :size="32"/> + <studip-icon v-if="item.type === 'experience'" shape="tools" role="clickable" :size="32"/> </div> <div class="cw-timeline-item-content cw-timeline-item-content-color-studip-blue" diff --git a/resources/vue/components/courseware/blocks/CoursewareBlockActions.vue b/resources/vue/components/courseware/blocks/CoursewareBlockActions.vue index 89beb0a..eea76fe 100644 --- a/resources/vue/components/courseware/blocks/CoursewareBlockActions.vue +++ b/resources/vue/components/courseware/blocks/CoursewareBlockActions.vue @@ -3,6 +3,7 @@ <studip-action-menu :items="menuItems" :context="block.attributes.title" + :collapseAt="1" @editBlock="editBlock" @setVisibility="setVisibility" @showInfo="showInfo" diff --git a/resources/vue/components/courseware/blocks/CoursewareBlockEdit.vue b/resources/vue/components/courseware/blocks/CoursewareBlockEdit.vue index d1ed09f..82d1abc 100644 --- a/resources/vue/components/courseware/blocks/CoursewareBlockEdit.vue +++ b/resources/vue/components/courseware/blocks/CoursewareBlockEdit.vue @@ -2,7 +2,7 @@ <section class="cw-block-edit"> <header v-if="preview">{{ $gettext('Bearbeiten') }}</header> <div class="cw-block-features-content"> - <div @click="deactivateToolbar(); exitHandler = true;"> + <div @click="exitHandler = true;"> <slot name="edit" /> </div> <div class="cw-button-box"> @@ -31,16 +31,6 @@ export default { beforeMount() { this.originalBlock = this.block; }, - methods: { - ...mapActions({ - coursewareBlockAdder: 'coursewareBlockAdder', - coursewareShowToolbar: 'coursewareShowToolbar' - }), - deactivateToolbar() { - this.coursewareBlockAdder({}); - this.coursewareShowToolbar(false); - }, - }, beforeDestroy() { if (this.exitHandler) { this.$emit('store'); diff --git a/resources/vue/components/courseware/blocks/CoursewareCanvasBlock.vue b/resources/vue/components/courseware/blocks/CoursewareCanvasBlock.vue index 669a5b8..24a59f5 100644 --- a/resources/vue/components/courseware/blocks/CoursewareCanvasBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareCanvasBlock.vue @@ -127,7 +127,7 @@ <studip-file-chooser v-model="currentFileId" selectable="file" - :courseId="studipContext.id" + :courseId="context.id" :userId="userId" :isImage="true" :excludedCourseFolderTypes="excludedCourseFolderTypes" @@ -187,7 +187,7 @@ export default { currentUserView: 'own', currentFile: {}, - context: {}, + canvasContext: {}, paint: false, write: false, clickX: [], @@ -221,7 +221,6 @@ export default { }, computed: { ...mapGetters({ - studipContext: 'context', fileRefById: 'file-refs/byId', getUserDataById: 'courseware-user-data-fields/byId', relatedUserData: 'user-data-field/related', @@ -301,9 +300,7 @@ export default { return this.currentUploadFolderId !== ""; }, canSwitchView() { - // this feature is not something to offer in the Arbeitsplatz! - let context = this.$store.getters.context; - if (context.type !== 'courses') { + if (this.context.type !== 'courses') { return false; } if (this.currentShowUserData === 'off') { @@ -419,7 +416,7 @@ export default { } else { canvas.height = 500; } - this.context = canvas.getContext('2d'); + this.canvasContext = canvas.getContext('2d'); this.setColor('blue'); this.currentSize = this.sizes['normal']; this.currentTool = this.tools['pen']; @@ -427,7 +424,7 @@ export default { }, redraw() { let view = this; - let context = view.context; + let context = view.canvasContext; context.clearRect(0, 0, context.canvas.width, context.canvas.height); // Clears the canvas context.fillStyle = '#ffffff'; context.fillRect(0, 0, context.canvas.width, context.canvas.height); // set background @@ -658,7 +655,7 @@ export default { }, async store() { let user = this.usersById({id: this.userId}); - let imageBase64 = this.context.canvas.toDataURL("image/jpeg", 1.0); + let imageBase64 = this.canvasContext.canvas.toDataURL("image/jpeg", 1.0); let image = await fetch(imageBase64); let imageBlob = await image.blob(); let file = {}; diff --git a/resources/vue/components/courseware/blocks/CoursewareDocumentBlock.vue b/resources/vue/components/courseware/blocks/CoursewareDocumentBlock.vue index 72cfeb3..55e7b01 100644 --- a/resources/vue/components/courseware/blocks/CoursewareDocumentBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareDocumentBlock.vue @@ -131,8 +131,8 @@ </button> <select v-model="currentScale" :aria-label="$gettext('Zoom')" @change="updateZoom"> <option v-show="false" :value="currentScale">{{ formattedZoom }}%</option> - <option v-for="(value, index) in scaleValues" :key="index" :value="value"> - {{ value * 100 }}% + <option v-for="(value, index) in scaleValues" :key="index" :value="value.scale"> + {{ value.name }} </option> </select> </div> @@ -305,7 +305,7 @@ export default { pdfAnnotationLayer: null, pdfAnnotation: false, pdfRotate: 0, - PdfViewer: null, + pdfViewer: null, pdfEventBus: null, pdfLinkService: null, pdfFindController: null, @@ -322,8 +322,8 @@ export default { pageNum: 1, pageCount: 0, scale: 1, + baseScale: 1, currentScale: 1, - scaleValues: [0.5, 1, 1.5, 2, 3, 4], file: null, srMessage: '', @@ -361,10 +361,23 @@ export default { formattedZoom() { return Number.parseInt(this.scale * 100, 10); }, + scaleValues() { + const defaultValues = [ + { name: '25%', scale: 0.25 }, + { name: '50%', scale: 0.5 }, + { name: '75%', scale: 0.75 }, + { name: '100%', scale: 1.0 }, + { name: '150%', scale: 1.5 }, + { name: '200%', scale: 2.0 }, + { name: '300%', scale: 3.0 }, + ]; + + return defaultValues.concat([{ name: this.$gettext('volle Breite'), scale: this.baseScale }]); + }, }, watch: { scale(newValue) { - let overflow = newValue > 1 ? 'auto' : 'hidden'; + let overflow = newValue > this.baseScale ? 'auto' : 'hidden'; let container = this.$refs.container; container.style.overflow = overflow; this.currentScale = newValue; @@ -407,7 +420,7 @@ export default { if (this.currentUrl) { let view = this; view.pdfEventBus = new EventBus(); - view.pdfLoadingTask = getDocument(this.currentUrl).promise; + view.pdfLoadingTask = getDocument({ url: this.currentUrl, verbosity: 0 }).promise; view.pdfLoadingTask.__PDFDocumentLoadingTask = true; // Link Service view.pdfLinkService = new PDFLinkService({ @@ -473,9 +486,16 @@ export default { .getPage(parseInt(view.pageNum)) .then((pdfPage) => { view.pdfPage = pdfPage; + const width = outerContainer.offsetWidth; + let pdfWidth = pdfPage.view[2]; + if (pdfPage.rotate === 90 || pdfPage.rotate === 270) { + pdfWidth = pdfPage.view[3]; + } + view.baseScale = (width / pdfWidth / 1.33).toFixed(2); + view.scale = view.baseScale; // Creating the page view with default parameters. let defaultViewport = pdfPage.getViewport({ - scale: 1.35, + scale: 1.0, }); view.pdfBasePage = new PDFViewer({ @@ -512,8 +532,6 @@ export default { view.pdfViewer.setPdfPage(view.pdfPage); // Set LinkService viewer view.pdfLinkService.setViewer(view.pdfViewer); - // Set outer container height - outerContainer.style.height = container.offsetHeight + 'px'; view.renderPage(); }) .catch((err) => { @@ -610,12 +628,12 @@ export default { this.updateSrMessage(this.$gettext('gedreht')); }, zoomIn() { - this.scale = this.scale < 4 ? (this.scale * 10 + 1) / 10 : this.scale; + this.scale = this.scale < 4 ? ((this.scale * 10 + 1) / 10).toFixed(1) : this.scale; this.renderPage(); this.updateSrMessage(this.$gettext('vergrößert')); }, zoomOut() { - this.scale = this.scale > 0.1 ? (this.scale * 10 - 1) / 10 : this.scale; + this.scale = this.scale > 0.1 ? ((this.scale * 10 - 1) / 10).toFixed(1) : this.scale; this.renderPage(); this.updateSrMessage(this.$gettext('verkleinert')); }, diff --git a/resources/vue/components/courseware/blocks/CoursewareIframeBlock.vue b/resources/vue/components/courseware/blocks/CoursewareIframeBlock.vue index be56ab4..4ff7e45 100644 --- a/resources/vue/components/courseware/blocks/CoursewareIframeBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareIframeBlock.vue @@ -234,17 +234,16 @@ export default { this.currentUrlIsValid = this.isValidUrl(this.currentUrl); }, isValidUrl(urlString) { - const urlPattern = new RegExp( - '^(https?:\\/\\/)?' + // validate protocol - '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // validate domain name - '((\\d{1,3}\\.){3}\\d{1,3}))' + // validate OR ip (v4) address - '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // validate port and path - '(\\?[;&a-z\\d%_.~+=-]*)?' + // validate query string - '(\\#[-a-z\\d_]*)?$', - 'i' - ); // validate fragment locator + if (!urlString.startsWith('http')) { + urlString = `${location.protocol}//${urlString}`; + } - return !!urlPattern.test(urlString); + try { + const url = new URL(urlString); + return ['http:', 'https:'].includes(url.protocol); + } catch (e) { + return false; + } }, updateUrl() { diff --git a/resources/vue/components/courseware/blocks/CoursewareImageMapBlock.vue b/resources/vue/components/courseware/blocks/CoursewareImageMapBlock.vue index 6d12980..4f52d4b 100644 --- a/resources/vue/components/courseware/blocks/CoursewareImageMapBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareImageMapBlock.vue @@ -113,7 +113,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> @@ -141,7 +141,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> diff --git a/resources/vue/components/courseware/blocks/CoursewareKeyPointBlock.vue b/resources/vue/components/courseware/blocks/CoursewareKeyPointBlock.vue index a410740..e52b608 100644 --- a/resources/vue/components/courseware/blocks/CoursewareKeyPointBlock.vue +++ b/resources/vue/components/courseware/blocks/CoursewareKeyPointBlock.vue @@ -38,7 +38,7 @@ v-model="currentColor" > <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.') }} @@ -57,7 +57,7 @@ {{ $gettext('Icon') }} <studip-select :options="icons" :clearable="false" 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.') }} diff --git a/resources/vue/components/courseware/containers/CoursewareContainerActions.vue b/resources/vue/components/courseware/containers/CoursewareContainerActions.vue index 79379bf..875ab1f 100644 --- a/resources/vue/components/courseware/containers/CoursewareContainerActions.vue +++ b/resources/vue/components/courseware/containers/CoursewareContainerActions.vue @@ -3,6 +3,7 @@ <studip-action-menu :items="menuItems" :context="container.attributes.title" + :collapseAt="1" @editContainer="editContainer" @changeContainer="changeContainer" @deleteContainer="deleteContainer" diff --git a/resources/vue/components/courseware/layouts/CoursewareCompanionBox.vue b/resources/vue/components/courseware/layouts/CoursewareCompanionBox.vue index f617e5a..f26c8d7 100644 --- a/resources/vue/components/courseware/layouts/CoursewareCompanionBox.vue +++ b/resources/vue/components/courseware/layouts/CoursewareCompanionBox.vue @@ -1,15 +1,9 @@ -<template> - <div class="cw-companion-box" :class="[mood]"> - <div> - <p v-html="msgCompanion"></p> - <slot name="companionActions"></slot> - </div> - </div> -</template> - <script> export default { name: 'courseware-companion-box', + render(createElement) { + return null; + }, props: { msgCompanion: String, mood: { @@ -20,5 +14,40 @@ export default { } } }, + computed: { + msgType() { + let type = 'info'; + switch (this.mood) { + case 'special': + case 'unsure': + type = 'warning'; + break; + case 'sad': + type = 'error'; + break; + case 'happy': + type = 'success'; + break + case 'pointing': + case 'curious': + } + return type; + } + }, + watch: { + msgCompanion: { + handler(current) { + if (current.trim().length === 0) { + return; + } + const notification = { + type: this.msgType, + message: current + }; + this.globalEmit('push-system-notification', notification); + }, + immediate: true + } + } }; -</script>
\ No newline at end of file +</script> diff --git a/resources/vue/components/courseware/layouts/CoursewareCompanionOverlay.vue b/resources/vue/components/courseware/layouts/CoursewareCompanionOverlay.vue index ff177f7..bfd0a93 100644 --- a/resources/vue/components/courseware/layouts/CoursewareCompanionOverlay.vue +++ b/resources/vue/components/courseware/layouts/CoursewareCompanionOverlay.vue @@ -1,28 +1,11 @@ -<template> - <div class="cw-companion-overlay-wrapper"> - <div - class="cw-companion-overlay" - :class="[showCompanion ? 'cw-companion-overlay-in' : '', showCompanion ? '' : 'cw-companion-overlay-out', styleCompanion]" - aria-hidden="true" - > - <div class="cw-companion-overlay-content" v-html="msgCompanion"></div> - <button class="cw-compantion-overlay-close" @click="hideCompanion"></button> - </div> - <div - class="sr-only" - aria-live="polite" - role="log" - > - <p>{{ msgCompanion }}</p> - </div> - </div> -</template> - <script> import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-companion-overlay', + render(createElement) { + return null; + }, computed: { ...mapGetters({ showCompanion: 'showCompanionOverlay', @@ -30,6 +13,24 @@ export default { styleCompanion: 'styleCompanionOverlay', showToolbar: 'showToolbar', }), + msgType() { + let type = 'info'; + switch (this.styleCompanion) { + case 'special': + case 'unsure': + type = 'warning'; + break; + case 'sad': + type = 'error'; + break; + case 'happy': + type = 'success'; + break + case 'pointing': + case 'curious': + } + return type; + } }, methods: { ...mapActions({ @@ -49,11 +50,24 @@ export default { } }, showToolbar(newValue, oldValue) { - // hide companion when toolbar is closed + // hide companion when toolbar is closed if (oldValue === true && newValue === false) { this.hideCompanion(); } + }, + msgCompanion: { + handler(current) { + if (current.trim().length === 0) { + return; + } + const notification = { + type: this.msgType, + message: current + }; + this.globalEmit('push-system-notification', notification); + }, + immediate: true } - }, + } }; </script> diff --git a/resources/vue/components/courseware/structural-element/CoursewareRootContent.vue b/resources/vue/components/courseware/structural-element/CoursewareRootContent.vue index 1a739a6..1aa8802 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareRootContent.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareRootContent.vue @@ -16,21 +16,21 @@ </div> <div v-else class="cw-root-content-wrapper"> <div class="cw-root-content" :class="['cw-root-content-' + rootLayout]"> - <div class="cw-root-content-img" :style="image"> + <div class="cw-root-content-img" :style="bgImage"> <section class="cw-root-content-description" :style="bgColor"> - <img v-if="imageIsSet" class="cw-root-content-description-img" :src="imageURL" /> - <template v-else> + <div class="cw-root-content-description-img" :src="imageURL" :style="image"></div> + <template v-if="!imageIsSet"> <studip-ident-image class="cw-root-content-description-img" - v-model="identImageCanvas" - :showCanvas="true" + v-model="identImage" :baseColor="bgColorHex" :pattern="structuralElement.attributes.title" /> <studip-ident-image - v-model="identImage" - :width="1095" - :height="withTOC ? 300 : 480" + v-model="identBgImage" + class="cw-root-content-description-background-img" + :width="4380" + :height="withTOC ? 1200 : 1920" :baseColor="bgColorHex" :pattern="structuralElement.attributes.title" /> @@ -46,44 +46,28 @@ </div> <div v-if="withTOC" class="cw-root-content-toc"> <ul class="cw-tiles"> - <li - v-for="child in childElements" - :key="child.id" - class="tile" - :class="[child.attributes.payload.color]" - > + <li v-for="child in childElements" :key="child.id"> <router-link :to="'/structural_element/' + child.id" :title="child.attributes.title"> - <div - v-if="hasImage(child)" - class="preview-image" - :style="getChildStyle(child)" - ></div> - <studip-ident-image - v-else - :baseColor="getColor(child).hex" - :pattern="child.attributes.title" - :showCanvas="true" - /> - <div class="description"> - <header - :class="[ - child.attributes.purpose !== '' - ? 'description-icon-' + child.attributes.purpose - : '', - ]" - > - {{ child.attributes.title || '–' }} - </header> - <div class="description-text-wrapper"> - <p>{{ child.attributes.payload.description }}</p> - </div> - <footer> - {{ countChildChildren(child) }} - <translate :translate-n="countChildChildren(child)" translate-plural="Seiten"> - Seite - </translate> - </footer> - </div> + <courseware-tile + tag="div" + :color="child.attributes.payload.color" + :title="child.attributes.title || '–'" + :imageUrl="hasImage(child) ? child.relationships?.image?.meta?.['download-url'] : ''" + > + <template #description> + {{ child.attributes.payload.description }} + </template> + <template #footer> + <p class="cw-root-content-toc-tile-footer"> + {{ + $gettextInterpolate( + $ngettext('%{pages} Seite', '%{pages} Seiten', countChildChildren(child)), + { pages: countChildChildren(child) } + ) + }} + </p> + </template> + </courseware-tile> </router-link> </li> </ul> @@ -93,6 +77,7 @@ <script> import CoursewareCompanionBox from './../layouts/CoursewareCompanionBox.vue'; +import CoursewareTile from './../layouts/CoursewareTile.vue'; import StudipIdentImage from './../../StudipIdentImage.vue'; import colorMixin from '@/vue/mixins/courseware/colors.js'; import { mapActions, mapGetters } from 'vuex'; @@ -100,18 +85,19 @@ import { mapActions, mapGetters } from 'vuex'; export default { name: 'courseware-root-content', mixins: [colorMixin], - props: { - structuralElement: Object, - canEdit: Boolean, - }, components: { CoursewareCompanionBox, + CoursewareTile, StudipIdentImage, }, + props: { + structuralElement: Object, + canEdit: Boolean, + }, data() { return { identImage: '', - identImageCanvas: '', + identBgImage: '', }; }, computed: { @@ -130,6 +116,13 @@ export default { image() { let style = {}; const backgroundURL = this.imageIsSet ? this.imageURL : this.identImage; + style.backgroundImage = 'url(' + backgroundURL + ')'; + + return style; + }, + bgImage() { + let style = {}; + const backgroundURL = this.imageIsSet ? this.imageURL : this.identBgImage; style.backgroundImage = 'url(' + backgroundURL + ')'; style.height = this.withTOC ? '300px' : '480px'; @@ -180,7 +173,7 @@ export default { }, addPage() { this.showElementAddDialog(true); - } + }, }, }; </script> @@ -196,16 +189,20 @@ export default { } .cw-root-content-description { display: flex; - flex-direction: row; - margin: 0 8em; - padding: 2em 4px 2em 2em; position: relative; - top: 8em; + flex-direction: column; + margin: 0 1em; + padding: 1em 4px 1em 1em; + top: 1em; + gap: 10px; .cw-root-content-description-img { - width: 240px; - height: fit-content; - margin-right: 2em; + min-width: 135px; + height: 90px; + background-color: #fff; + background-size: cover; + background-position: center; + margin-right: 1em; } .cw-root-content-description-text { max-height: calc(480px - 18em); @@ -233,14 +230,68 @@ export default { max-width: 1095px; margin-bottom: 1em; .cw-root-content-description { - margin: 0 8em; - top: 1.5em; + height: calc(100% - 4em); .cw-root-content-description-text { max-height: calc(300px - 6em); } } + .cw-root-content-toc-tile-footer { + line-height: 4em; + } } .cw-root-content-hint { max-width: 1095px; } + +.size-small { + .cw-root-content-description { + flex-direction: row; + padding: 1em 4px 1em 1em; + + .cw-root-content-description-img { + min-width: 135px; + height: 90px; + } + } + + .cw-root-content-default { + .cw-root-content-description { + margin: 0 4em; + top: 8em; + } + } + .cw-root-content-toc { + .cw-root-content-description { + height: calc(100% - 6em); + margin: 0 4em; + top: 1.5em; + } + } +} + +.size-large { + .cw-root-content-description { + flex-direction: row; + padding: 2em 4px 2em 2em; + + .cw-root-content-description-img { + min-width: 270px; + height: 180px; + } + } + + .cw-root-content-default { + .cw-root-content-description { + margin: 0 8em; + top: 8em; + } + } + .cw-root-content-toc { + .cw-root-content-description { + height: calc(100% - 7em); + margin: 0 8em; + top: 1.5em; + } + } +} </style> diff --git a/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDialogImport.vue b/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDialogImport.vue index 60b404e..019bbf2 100644 --- a/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDialogImport.vue +++ b/resources/vue/components/courseware/structural-element/CoursewareStructuralElementDialogImport.vue @@ -130,7 +130,6 @@ export default { this.importZipFile = event.target.files[0]; this.setImportFilesProgress(0); this.setImportStructuresProgress(0); - this.setImportErrors([]); }, async importCoursewareArchiv() { this.importAborted = false; @@ -199,6 +198,9 @@ export default { await this.importCourseware(courseware, this.currentElement, files, this.importBehavior, null); } + }, + mounted() { + this.setImportErrors([]); } } </script> diff --git a/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue index 1e076c7..7680167 100644 --- a/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue +++ b/resources/vue/components/courseware/tasks/CoursewareTasksDialogDistribute.vue @@ -179,7 +179,7 @@ :aria-label=" $gettextInterpolate($gettext('%{userName} auswählen'), { userName: user.formattedname, - }) + }, true) " /> </td> @@ -217,7 +217,7 @@ :aria-label=" $gettextInterpolate($gettext('%{groupName} auswählen'), { groupName: group.name, - }) + }, true) " /> </td> diff --git a/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue b/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue index a843341..d7db6e1 100644 --- a/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue +++ b/resources/vue/components/courseware/tasks/TaskGroupsAddSolversDialog.vue @@ -44,7 +44,7 @@ :aria-label=" $gettextInterpolate($gettext('%{userName} auswählen'), { userName: user.formattedname, - }) + }, true) " /> </td> @@ -77,7 +77,7 @@ :aria-label=" $gettextInterpolate($gettext('%{groupName} auswählen'), { groupName: group.name, - }) + }, true) " /> </td> diff --git a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue index d31e963..dc70348 100644 --- a/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue +++ b/resources/vue/components/courseware/toolbar/CoursewareToolbar.vue @@ -2,7 +2,7 @@ <div class="cw-toolbar-wrapper"> <div id="cw-toolbar" class="cw-toolbar" :style="toolbarStyle"> <div v-if="showTools" class="cw-toolbar-tools" :class="{ unfold: unfold, hd: isHd, wqhd: isWqhd }"> - <div class="cw-toolbar-button-wrapper"> + <div id="cw-toolbar-nav" class="cw-toolbar-button-wrapper"> <button class="cw-toolbar-button" :class="{ active: activeTool === 'blockAdder' }" @@ -35,9 +35,19 @@ <studip-icon shape="arr_2right" :size="24" /> </button> </div> - <courseware-toolbar-blocks v-if="activeTool === 'blockAdder'" /> - <courseware-toolbar-containers v-if="activeTool === 'containerAdder'" /> - <courseware-toolbar-clipboard v-if="activeTool === 'clipboard'" /> + <div class="cw-toolbar-tool-wrapper"> + <CoursewareToolbarBlocks + v-if="activeTool === 'blockAdder'" + :toolbarContentHeight="toolbarContentHeight" + /> + <CoursewareToolbarContainers + v-if="activeTool === 'containerAdder'" + /> + <CoursewareToolbarClipboard + v-if="activeTool === 'clipboard'" + :toolbarContentHeight="toolbarContentHeight" + /> + </div> </div> <div v-else class="cw-toolbar-folded-wrapper"> <button @@ -97,20 +107,26 @@ export default { toolbarActive: 'toolbarActive', hideEditLayout: 'hideEditLayout', }), - toolbarStyle() { - const scrollTopStyles = window.getComputedStyle(document.getElementById('scroll-to-top')); + scrollTopStyles() { + return window.getComputedStyle(document.getElementById('scroll-to-top')); + }, + toolbarHeight() { const scrollTopHeight = - parseInt(scrollTopStyles['height'], 10) + - parseInt(scrollTopStyles['padding-top'], 10) + - parseInt(scrollTopStyles['padding-bottom'], 10) + - parseInt(scrollTopStyles['margin-bottom'], 10); - let height = parseInt( + parseInt(this.scrollTopStyles['height'], 10) + + parseInt(this.scrollTopStyles['padding-top'], 10) + + parseInt(this.scrollTopStyles['padding-bottom'], 10) + + parseInt(this.scrollTopStyles['margin-bottom'], 10); + return parseInt( Math.min(this.windowInnerHeight * 0.9, this.windowInnerHeight - this.toolbarTop - scrollTopHeight) ); - + }, + toolbarContentHeight() { + return this.toolbarHeight - 55; + }, + toolbarStyle() { return { - height: height + 'px', - minHeight: height + 'px', + height: this.toolbarHeight + 'px', + minHeight: this.toolbarHeight + 'px', top: this.toolbarTop + 'px', }; }, @@ -183,8 +199,8 @@ export default { }, watch: { - containers(oldValue, newValue) { - if (oldValue && newValue && oldValue.length !== newValue.length) { + containers(newValue, oldValue) { + if (newValue) { this.resetAdderStorage(); } }, diff --git a/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue b/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue index 2795e62..ceda0f0 100644 --- a/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue +++ b/resources/vue/components/courseware/toolbar/CoursewareToolbarBlocks.vue @@ -1,85 +1,88 @@ <template> <div class="cw-toolbar-blocks"> - <form @submit.prevent="loadSearch"> - <div class="input-group files-search search cw-block-search"> - <input - ref="searchBox" - type="text" - v-model="searchInput" - @click.stop - :label="$gettext('Geben Sie einen Suchbegriff mit mindestens 3 Zeichen ein.')" - /> - <span class="input-group-append" @click.stop> - <button - v-if="searchInput" - type="button" - class="button reset-search" - id="reset-search" - :title="$gettext('Suche zurücksetzen')" - @click="resetSearch" - > - <studip-icon shape="decline" :size="20"></studip-icon> - </button> - <button - type="submit" - class="button" - id="search-btn" - :title="$gettext('Suche starten')" - @click="loadSearch" - > - <studip-icon shape="search" :size="20"></studip-icon> - </button> - </span> - </div> - </form> + <div id="cw-toolbar-blocks-header" class="cw-toolbar-tool-header"> + <form @submit.prevent="loadSearch"> + <div class="input-group files-search search cw-block-search"> + <input + ref="searchBox" + type="text" + v-model="searchInput" + @click.stop + :label="$gettext('Geben Sie einen Suchbegriff mit mindestens 3 Zeichen ein.')" + /> + <span class="input-group-append" @click.stop> + <button + v-if="searchInput" + type="button" + class="button reset-search" + id="reset-search" + :title="$gettext('Suche zurücksetzen')" + @click="resetSearch" + > + <studip-icon shape="decline" :size="20"></studip-icon> + </button> + <button + type="submit" + class="button" + id="search-btn" + :title="$gettext('Suche starten')" + @click="loadSearch" + > + <studip-icon shape="search" :size="20"></studip-icon> + </button> + </span> + </div> + </form> - <div class="filterpanel"> - <span class="sr-only">{{ $gettext('Kategorien-Filter') }}</span> - <button - v-for="category in blockCategories" - :key="category.type" - class="button" - :class="{ 'button-active': category.type === currentFilterCategory }" - :aria-pressed="category.type === currentFilterCategory ? 'true' : 'false'" - @click="selectCategory(category.type)" - > - {{ category.title }} - </button> + <div class="filterpanel"> + <span class="sr-only">{{ $gettext('Kategorien-Filter') }}</span> + <button + v-for="category in blockCategories" + :key="category.type" + class="button" + :class="{ 'button-active': category.type === currentFilterCategory }" + :aria-pressed="category.type === currentFilterCategory ? 'true' : 'false'" + @click="selectCategory(category.type)" + > + {{ category.title }} + </button> + </div> </div> - - <div v-if="filteredBlockTypes.length > 0" class="cw-blockadder-item-list"> - <draggable - v-if="filteredBlockTypes.length > 0" - class="cw-blockadder-item-list" - tag="div" - role="listbox" - v-model="filteredBlockTypes" - handle=".cw-sortable-handle-blockadder" - :group="{ name: 'blocks', pull: 'clone', put: 'false' }" - :clone="cloneBlock" - :sort="false" - :emptyInsertThreshold="20" - @start="dragBlockStart($event)" - @end="dropNewBlock($event)" - ref="sortables" - sectionId="0" - > - <courseware-blockadder-item - v-for="(block, index) in filteredBlockTypes" - :key="index" - :title="block.title" - :type="block.type" - :data-blocktype="block.type" - :description="block.description" - @blockAdded="$emit('blockAdded')" - /> - </draggable> + <div class="cw-toolbar-tool-content" :style="toolContentStyle"> + <div v-if="filteredBlockTypes.length > 0" class="cw-blockadder-item-list"> + <draggable + v-if="filteredBlockTypes.length > 0" + class="cw-blockadder-item-list" + tag="div" + role="listbox" + v-model="filteredBlockTypes" + handle=".cw-sortable-handle-blockadder" + :group="{ name: 'blocks', pull: 'clone', put: 'false' }" + :clone="cloneBlock" + :sort="false" + :emptyInsertThreshold="20" + @start="dragBlockStart($event)" + @end="dropNewBlock($event)" + ref="sortables" + sectionId="0" + > + <courseware-blockadder-item + v-for="(block, index) in filteredBlockTypes" + :key="index" + :title="block.title" + :type="block.type" + :data-blocktype="block.type" + :description="block.description" + @blockAdded="$emit('blockAdded')" + /> + </draggable> + </div> + <courseware-companion-box + v-else + :msgCompanion="$gettext('Es wurden keine passenden Blöcke gefunden.')" + mood="pointing" + /> </div> - <courseware-companion-box - v-else - :msgCompanion="$gettext('Es wurden keine passenden Blöcke gefunden.')" - mood="pointing" - /> </div> </template> @@ -99,6 +102,12 @@ export default { CoursewareCompanionBox, draggable, }, + props: { + toolbarContentHeight: { + type: Number, + required: true, + }, + }, data() { return { searchInput: '', @@ -132,6 +141,13 @@ export default { { title: this.$gettext('Biografie'), type: 'biography' }, ]; }, + toolContentStyle() { + const height = this.toolbarContentHeight - 115; + + return { + height: height + 'px', + }; + }, }, methods: { ...mapActions({ diff --git a/resources/vue/components/courseware/toolbar/CoursewareToolbarClipboard.vue b/resources/vue/components/courseware/toolbar/CoursewareToolbarClipboard.vue index 98cd573..c6f1e92 100644 --- a/resources/vue/components/courseware/toolbar/CoursewareToolbarClipboard.vue +++ b/resources/vue/components/courseware/toolbar/CoursewareToolbarClipboard.vue @@ -1,5 +1,5 @@ <template> - <div class="cw-toolbar-clipboard"> + <div class="cw-toolbar-clipboard cw-toolbar-tool-content" :style="toolContentStyle"> <courseware-collapsible-box :title="$gettext('Blöcke')" :open="clipboardBlocks.length > 0"> <template v-if="clipboardBlocks.length > 0"> <div class="cw-element-inserter-wrapper"> @@ -101,7 +101,12 @@ export default { StudipDialog, draggable, }, - + props: { + toolbarContentHeight: { + type: Number, + required: true, + }, + }, data() { return { showDeleteClipboardDialog: false, @@ -143,6 +148,11 @@ export default { } return ''; }, + toolContentStyle() { + return { + height: this.toolbarContentHeight + 'px', + }; + }, }, methods: { ...mapActions({ diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue index f09601e..dc68225 100644 --- a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue +++ b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue @@ -63,7 +63,7 @@ :question=" $gettextInterpolate($gettext('Möchten Sie das Lernmaterial %{ unitTitle } wirklich löschen?'), { unitTitle: title, - }) + }, true) " height="200" @confirm="executeDelete" diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItemDialogLayout.vue b/resources/vue/components/courseware/unit/CoursewareUnitItemDialogLayout.vue index f956e47..572e171 100644 --- a/resources/vue/components/courseware/unit/CoursewareUnitItemDialogLayout.vue +++ b/resources/vue/components/courseware/unit/CoursewareUnitItemDialogLayout.vue @@ -1,11 +1,11 @@ <template> - <studip-dialog + <studip-dialog :title="$gettext('Darstellung')" :confirmText="$gettext('Speichern')" confirmClass="accept" :closeText="$gettext('Schließen')" closeClass="cancel" - height="470" + height="540" width="870" @close="$emit('close')" @confirm="storeLayout" @@ -23,12 +23,8 @@ <label v-if="showPreviewImage"> <button class="button" @click="deleteImage">{{ $gettext('Bild löschen') }}</button> </label> - <courseware-companion-box - v-if="uploadFileError" - :msgCompanion="uploadFileError" - mood="sad" - /> - <label v-if="!showPreviewImage"> + <courseware-companion-box v-if="uploadFileError" :msgCompanion="uploadFileError" mood="sad" /> + <template v-if="!showPreviewImage"> <img v-if="currentFile" :src="uploadImageURL" @@ -36,14 +32,32 @@ :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> + {{ $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" + /> + </template> </form> <form class="default cw-unit-item-dialog-layout-content-settings" @submit.prevent=""> <label> {{ $gettext('Titel') }} - <input type="text" v-model="currentElement.attributes.title"/> + <input type="text" v-model="currentElement.attributes.title" /> </label> <label> {{ $gettext('Beschreibung') }} @@ -62,13 +76,9 @@ class="cw-vs-select" > <template #open-indicator="selectAttributes"> - <span v-bind="selectAttributes" - ><studip-icon shape="arr_1down" :size="10" - /></span> - </template> - <template #no-options> - {{ $gettext('Es steht keine Auswahl zur Verfügung') }}. + <span v-bind="selectAttributes"><studip-icon shape="arr_1down" :size="10" /></span> </template> + <template #no-options> {{ $gettext('Es steht keine Auswahl zur Verfügung') }}. </template> <template #selected-option="{ name, hex }"> <span class="vs__option-color" :style="{ 'background-color': hex }"></span ><span>{{ name }}</span> @@ -96,19 +106,19 @@ <script> import CoursewareCompanionBox from '../layouts/CoursewareCompanionBox.vue'; - +import StockImageSelector from '../../stock-images/SelectorDialog.vue'; import colorMixin from '@/vue/mixins/courseware/colors.js'; import { mapActions, mapGetters } from 'vuex'; - export default { name: 'courseware-unit-item-dialog-layout', components: { - CoursewareCompanionBox + CoursewareCompanionBox, + StockImageSelector, }, props: { unit: Object, - unitElement: Object + unitElement: Object, }, mixins: [colorMixin], data() { @@ -120,18 +130,26 @@ export default { uploadImageURL: null, currentRootLayout: 'default', loadingInstance: false, - } + showStockImageSelector: false, + selectedStockImage: null, + }; }, computed: { ...mapGetters({ context: 'context', instanceById: 'courseware-instances/byId', - userId: 'userId' + userId: 'userId', }), colors() { - return this.mixinColors.filter(color => color.darkmode); + return this.mixinColors.filter((color) => color.darkmode); }, image() { + if (this.selectedStockImage) { + return this.selectedStockImage.attributes['download-urls'].small + } + if (this.uploadImageURL) { + return this.uploadImageURL; + } return this.currentElement.relationships?.image?.meta?.['download-url'] ?? null; }, @@ -140,15 +158,14 @@ export default { }, instance() { if (this.inCourseContext) { - return this.instanceById({id: 'course_' + this.context.id + '_' + this.unit.id}); + return this.instanceById({ id: 'course_' + this.context.id + '_' + this.unit.id }); } else { - return this.instanceById({id: 'user_' + this.context.id + '_' + this.unit.id}); + return this.instanceById({ id: 'user_' + this.context.id + '_' + this.unit.id }); } - }, inCourseContext() { return this.context.type === 'courses'; - } + }, }, methods: { ...mapActions({ @@ -162,9 +179,10 @@ export default { uploadImageForStructuralElement: 'uploadImageForStructuralElement', deleteImageForStructuralElement: 'deleteImageForStructuralElement', storeCoursewareSettings: 'storeCoursewareSettings', + setStockImageForStructuralElement: 'setStockImageForStructuralElement', }), async loadUnitInstance() { - const context = {type: this.context.type, id: this.context.id, unit: this.unit.id}; + const context = { type: this.context.type, id: this.context.id, unit: this.unit.id }; await this.loadInstance(context); }, initData() { @@ -192,11 +210,13 @@ export default { this.$emit('close'); await this.loadStructuralElement(this.currentElement.id); if ( - this.unitElement.relationships['edit-blocker'].data !== null - && this.unitElement.relationships['edit-blocker'].data?.id !== this.userId + this.unitElement.relationships['edit-blocker'].data !== null && + this.unitElement.relationships['edit-blocker'].data?.id !== this.userId ) { this.companionWarning({ - info: this.$gettext('Ihre Änderungen konnten nicht gespeichert werden, die Daten werden bereits von einem anderen Nutzer bearbeitet.') + info: this.$gettext( + 'Ihre Änderungen konnten nicht gespeichert werden, die Daten werden bereits von einem anderen Nutzer bearbeitet.' + ), }); return false; } else { @@ -207,13 +227,20 @@ export default { this.uploadImageForStructuralElement({ structuralElement: this.currentElement, file: this.currentFile, - }).then(() => { - this.loadStructuralElement(this.currentElement.id) - }).catch((error) => { - console.error(error); - this.companionWarning({ - info: this.$gettext('Beim Hochladen der Bilddatei ist ein Fehler aufgetretten.') + }) + .then(() => { + this.loadStructuralElement(this.currentElement.id); + }) + .catch((error) => { + console.error(error); + this.companionWarning({ + info: this.$gettext('Beim Hochladen der Bilddatei ist ein Fehler aufgetretten.'), + }); }); + } else if (this.selectedStockImage) { + await this.setStockImageForStructuralElement({ + structuralElement: this.currentElement, + stockImage: this.selectedStockImage, }); } else if (this.deletingPreviewImage) { await this.deleteImageForStructuralElement(this.currentElement); @@ -233,13 +260,21 @@ export default { instance: currentInstance, }); } - } + }, + onSelectStockImage(stockImage) { + if (this.$refs?.upload_image) { + this.$refs.upload_image.value = null; + } + this.selectedStockImage = stockImage; + this.showStockImageSelector = false; + this.deletingPreviewImage = false; + }, }, async mounted() { this.loadingInstance = true; await this.loadUnitInstance(); this.loadingInstance = false; this.initData(); - } -} + }, +}; </script> diff --git a/resources/vue/components/courseware/unit/CoursewareUnitProgress.vue b/resources/vue/components/courseware/unit/CoursewareUnitProgress.vue index 7c9de01..31c6c01 100644 --- a/resources/vue/components/courseware/unit/CoursewareUnitProgress.vue +++ b/resources/vue/components/courseware/unit/CoursewareUnitProgress.vue @@ -17,7 +17,7 @@ <h1> <a :href="chapterUrl" - :title="$gettextInterpolate('%{ pageTitle } öffnen', { pageTitle: selected.name })" + :title="$gettextInterpolate('%{ pageTitle } öffnen', { pageTitle: selected.name }, true)" > {{ selected.name }} </a> diff --git a/resources/vue/components/file-chooser/FileChooserDialog.vue b/resources/vue/components/file-chooser/FileChooserDialog.vue index 2102d1e..2b21f51 100644 --- a/resources/vue/components/file-chooser/FileChooserDialog.vue +++ b/resources/vue/components/file-chooser/FileChooserDialog.vue @@ -93,8 +93,8 @@ export default { }, data() { return { - height: 600, - width: 1000, + height: '600', + width: '1000', scope: 'courses', coursesTree: [], usersTree: [], diff --git a/resources/vue/components/form_inputs/CalendarPermissionsTable.vue b/resources/vue/components/form_inputs/CalendarPermissionsTable.vue index a0a76a2..e507adc 100644 --- a/resources/vue/components/form_inputs/CalendarPermissionsTable.vue +++ b/resources/vue/components/form_inputs/CalendarPermissionsTable.vue @@ -30,14 +30,16 @@ v-model="user.write_permissions" :aria-label="$gettextInterpolate( $gettext('Schreibzugriff für %{name}'), - {name: user.name} + {name: user.name}, + true )"> </td> <td class="actions"> <studip-icon shape="trash" aria-role="button" @click="removeContact(user.id)" :title="$gettextInterpolate( $gettext('Kalender nicht mehr mit %{name} teilen'), - {name: user.name} + {name: user.name}, + true )"></studip-icon> </td> </tr> diff --git a/resources/vue/components/form_inputs/CaptchaInput.vue b/resources/vue/components/form_inputs/CaptchaInput.vue new file mode 100644 index 0000000..1409aa8 --- /dev/null +++ b/resources/vue/components/form_inputs/CaptchaInput.vue @@ -0,0 +1,70 @@ +<template> + <div class="formpart"> + <altcha-widget :challengeurl="challengeUrl" ref="widget"></altcha-widget> + </div> +</template> +<script> +import 'altcha'; +import { $gettext } from '../../../assets/javascripts/lib/gettext'; + +export default { + name: 'CaptchaInput', + props: { + name: { + type: String, + default: 'altcha' + }, + challengeUrl: { + type: String, + requird: true, + }, + auto: { + type: String, + default: null, + validator: (value) => ['onfocus', 'onload', 'onsubmit'].includes(value), + } + }, + data() { + return {}; + }, + methods: { + }, + mounted() { + this.$nextTick(() => { + this.$refs.widget.configure({ + auto: this.auto, + name: this.name, + hidefooter: false, + hidelogo: false, + strings: { + error: $gettext('Überprüfung fehlgeschlagen. Versuchen Sie es später erneut.'), + footer: $gettext('Geschützt von <a href="https://altcha.org/" target="_blank">ALTCHA</a>'), + label: $gettext('Ich bin kein Bot'), + verified: $gettext('Überprüft'), + verifying: $gettext('Überprüfung...'), + waitAlert: $gettext('Überprüfung... Bitte warten.'), + }, + }); + + this.$refs.widget.addEventListener('statechange', (ev) => { + if (ev.detail.state === 'verified') { + this.$emit('input', ev.detail.payload); + } + }) + }); + } +} +</script> +<style> +:root { + --altcha-border-width: 0; + --altcha-border-radius: 0; + --altcha-color-base: transparent; + --altcha-color-border: #a0a0a0; + --altcha-color-text: currentColor; + --altcha-color-border-focus: currentColor; + --altcha-color-error-text: var(--red); + --altcha-color-footer-bg: none; + --altcha-max-width: auto; +} +</style> diff --git a/resources/vue/components/form_inputs/DateListInput.vue b/resources/vue/components/form_inputs/DateListInput.vue index 05f3c57..d77c993 100644 --- a/resources/vue/components/form_inputs/DateListInput.vue +++ b/resources/vue/components/form_inputs/DateListInput.vue @@ -2,11 +2,11 @@ <div class="formpart"> <div class="sr-only" aria-live="polite" ref="list_message_field"></div> <ul> - <li v-for="date in selected_date_list" v-bind="selected_date_list" :key="date"> + <li v-for="date in selected_date_list" v-bind="selected_date_list" :key="getISODate(date)"> <input type="hidden" :name="input_name + '[]'" :value="getISODate(date)"> <studip-date-time :timestamp="Math.floor(date.getTime() / 1000)" :date_only="true"></studip-date-time> - <studip-icon shape="trash" :title="$gettext('Löschen')" @click="removeDate" - class="enter-accessible" aria-role="button" tabindex="0"></studip-icon> + <studip-icon shape="trash" :title="$gettext('Löschen')" @click="removeDate(date)" + class="icon enter-accessible button undecorated" aria-role="button" tabindex="0"></studip-icon> </li> </ul> <label> @@ -82,13 +82,13 @@ export default { this.selected_date_list.push(new Date(reformatted_date)); this.$refs.list_message_field.innerText = $gettextInterpolate($gettext('Datum %{date} hinzugefügt'), {date: this.selected_date_value}); }, - removeDate(date_key) { - if (date_key) { - let date = this.selected_date_list.at(date_key); - let formatted_date = STUDIP.DateTime.getStudipDate(date, false, true); - this.selected_date_list.splice(date_key, 1); - this.$refs.list_message_field.innerText = $gettextInterpolate($gettext('Datum %{date} entfernt'), {date: formatted_date}); - } + removeDate(date) { + this.selected_date_list = this.selected_date_list.filter(d => d !== date); + + this.$refs.list_message_field.innerText = $gettextInterpolate( + $gettext('Datum %{date} entfernt'), + {date: STUDIP.DateTime.getStudipDate(date, false, true)} + ); }, getISODate(date) { return STUDIP.DateTime.getISODate(date); diff --git a/resources/vue/components/form_inputs/MyCoursesColouredTable.vue b/resources/vue/components/form_inputs/MyCoursesColouredTable.vue index d2cc2f1..fe67db5 100644 --- a/resources/vue/components/form_inputs/MyCoursesColouredTable.vue +++ b/resources/vue/components/form_inputs/MyCoursesColouredTable.vue @@ -42,7 +42,7 @@ <input type="hidden" :name="`${name}_course_ids[${course.id}]`" value="0"> <input type="checkbox" :name="`${name}_course_ids[${course.id}]`" value="1" :checked="selected_course_id_list.includes(course.id)" - :title="$gettextInterpolate($gettext('%{course} auswählen'), {course: course.name})"> + :title="$gettextInterpolate($gettext('%{course} auswählen'), {course: course.name}, true)"> </td> </tr> <tr v-if="loadedSemesters.includes(semester_id) && courses.length === 0"> diff --git a/resources/vue/components/questionnaires/FreetextEdit.vue b/resources/vue/components/questionnaires/FreetextEdit.vue index 29c6f34..58848ed 100644 --- a/resources/vue/components/questionnaires/FreetextEdit.vue +++ b/resources/vue/components/questionnaires/FreetextEdit.vue @@ -2,7 +2,7 @@ <div> <div class="formpart" tabindex="0" ref="autofocus"> {{ $gettext('Frage') }} - <studip-wysiwyg v-model="val_clone.description" :key="question_id"></studip-wysiwyg> + <StudipWysiwyg v-model="val_clone.description" /> </div> <label> @@ -13,40 +13,19 @@ </template> <script> -import StudipWysiwyg from "../StudipWysiwyg.vue"; +import { QuestionnaireComponent } from '../../mixins/QuestionnaireComponent'; export default { name: 'freetext-edit', - components: { - StudipWysiwyg + mixins: [ QuestionnaireComponent ], + created() { + this.setDefaultValues({ + description: '', + mandatory: '0', + }); }, - props: { - value: { - type: Object, - required: false, - default: function () { - return {}; - } - }, - question_id: { - type: String, - required: false - } - }, - data: function () { - return { - val_clone: '' - }; - }, - mounted: function () { - this.val_clone = this.value; + mounted() { this.$refs.autofocus.focus(); - }, - watch: { - value (new_val) { - this.val_clone = new_val; - } } - } </script> diff --git a/resources/vue/components/questionnaires/InputArray.vue b/resources/vue/components/questionnaires/InputArray.vue index 37fa6fc..896418f 100644 --- a/resources/vue/components/questionnaires/InputArray.vue +++ b/resources/vue/components/questionnaires/InputArray.vue @@ -1,177 +1,170 @@ <template> <div class="input-array"> <span aria-live="assertive" class="sr-only">{{ assistiveLive }}</span> - <draggable v-model="options" handle=".dragarea" tag="ol" class="clean options"> - <li v-for="(option, index) in options" :key="index"> - <a class="dragarea" - v-if="options.length > 1" - tabindex="0" - :ref="'draghandle_' + index" - :title="$gettextInterpolate('Sortierelement für Option %{option}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.', {option: option})" - @keydown="keyHandler($event, index)"> - <span class="drag-handle"></span> - </a> - <input type="text" - :placeholder="$gettext('Option')" - :ref="'option_' + index" - @paste="(ev) => onPaste(ev, index)" - v-model="options[index]"> - <button class="as-link" - :title="$gettext('Option löschen')" - @click.prevent="askForDeletingOption(index)"> - <studip-icon shape="trash" role="clickable" :size="20" alt=""></studip-icon> - </button> - <button v-if="index == options.length - 1" - class="as-link" - :title="$gettext('Option hinzufügen')" - @click.prevent="addOption"> - <studip-icon shape="add" role="clickable" :size="20" alt=""></studip-icon> - </button> - </li> - </draggable> - <studip-dialog - v-if="askForDeleting" - :title="$gettext('Bitte bestätigen Sie die Aktion.')" - :question="$gettext('Wirklich löschen?')" - :confirmText="$gettext('Ja')" - :closeText="$gettext('Nein')" - closeClass="cancel" - height="180" - @confirm="deleteOption" - @close="askForDeleting = false" - > - </studip-dialog> + <table class="default nohover"> + <colgroup> + <col style="width: 16px"> + <col> + <col v-for="i in additionalColspan" :key="`colspan-${i}`"> + <col style="width: 24px"> + </colgroup> + <thead> + <tr> + <th class="dragcolumn"></th> + <th>{{ labelPlural }}</th> + <slot name="header-cells" /> + <th class="actions"></th> + </tr> + </thead> + <Draggable v-model="options" handle=".dragarea" tag="tbody" class="statements"> + <tr v-for="(option, index) in options" :key="index"> + <td class="dragcolumn"> + <a class="dragarea" + tabindex="0" + :title="$gettextInterpolate($gettext(`Sortierelement für %{label} %{option}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.`), {option, label}, true)" + @keydown="keyHandler($event, index)" + ref="draghandle"> + <span class="drag-handle"></span> + </a> + </td> + <td> + <input type="text" + ref="inputs" + :placeholder="label" + @paste="(ev) => onPaste(ev, index)" + v-model="options[index]"> + </td> + <slot name="body-cells" /> + <td class="actions"> + <StudipIcon name="delete" + shape="trash" + :size="20" + @click.prevent="deleteOption(index)" + :title="$gettextInterpolate($gettext('%{label} löschen'), {label}, true)" + /> + </td> + </tr> + </Draggable> + <tfoot> + <tr> + <td :colspan="3 + additionalColspan"> + <button class="as-link" + :title="$gettextInterpolate($gettext('%{label} hinzufügen'), {label}, true)" + @click.prevent="addOption()"> + <StudipIcon shape="add" :size="20" alt="" /> + </button> + </td> + </tr> + </tfoot> + </table> </div> </template> <script> -import StudipIcon from "../StudipIcon.vue"; -import StudipDialog from "../StudipDialog.vue"; -import draggable from 'vuedraggable'; +import Draggable from 'vuedraggable'; +import { $gettext } from '../../../assets/javascripts/lib/gettext'; + export default { name: 'input-array', - components: { - StudipIcon, - StudipDialog, - draggable - }, + components: { Draggable }, props: { - value: { - type: Array, - required: false - } + additionalColspan: { + type: Number, + default: 0, + }, + label: { + type: String, + default: $gettext('Option'), + }, + labelPlural: { + type: String, + default: $gettext('Optionen'), + }, + value: Array, }, - data: function () { + data() { return { options: [], - askForDeleting: false, - indexOfDeletingOption: 0, - unique_id: null, - assistiveLive: '' + assistiveLive: '', }; }, methods: { - addOption: function (val, position) { - let data = this.value; - if (val.target) { - val = ''; - } - if (typeof position === "undefined") { - data.push(val || ''); - position = this.value.length - 1 - } else { - data.splice(position, 0, val || ''); - } - this.$emit('input', data); - let v = this; - this.$nextTick(function () { - v.$refs['option_' + position][0].focus(); + addOption(val = '', position = this.options.length) { + this.$set(this.options, position, val.trim()); + + this.$nextTick(() => { + this.$refs.inputs[position].focus(); }); }, - askForDeletingOption: function (index) { - this.indexOfDeletingOption = index; - if (this.value[index]) { - this.askForDeleting = true; - } else { - this.deleteOption(); - } - }, - deleteOption: function () { - this.$delete(this.value, this.indexOfDeletingOption); - this.askForDeleting = false; + deleteOption(index) { + const question = this.options[index] ? this.$gettext('Wirklich löschen?') : true; + STUDIP.Dialog.confirm(question).done(() => { + this.$delete(this.options, index); + }); }, - onPaste: function (ev, position) { - let data = ev.clipboardData.getData("text").split("\n"); - for (let i = 0; i < data.length; i++) { - if (data[i].trim()) { - this.addOption(data[i], position + i); - } - } + onPaste(ev, position) { + ev.clipboardData + .getData('text') + .split("\n") + .filter(str => str.trim().length > 0) + .forEach((value, index) => this.addOption(value, position + index)); + ev.preventDefault(); }, keyHandler(e, index) { - switch (e.keyCode) { - case 38: // up - e.preventDefault(); - if (index > 0) { - this.moveUp(index); - this.$nextTick(function () { - this.$refs['draghandle_' + (index - 1)][0].focus(); - this.assistiveLive = this.$gettextInterpolate( - 'Aktuelle Position in der Liste: %{pos} von %{listLength}.' - , {pos: index, listLength: this.options.length} - ); - }); - } - break; - case 40: // down - e.preventDefault(); - if (index < this.options.length - 1) { - this.moveDown(index); - this.$nextTick(function () { - this.$refs['draghandle_' + (index + 1)][0].focus(); - this.assistiveLive = this.$gettextInterpolate( - 'Aktuelle Position in der Liste: %{pos} von %{listLength}.' - , {pos: index + 2, listLength: this.options.length} - ); - }); - } - break; - } - }, - moveDown: function (index) { - if (index == this.options.length - 1) { + if (e.keyCode !== 38 && e.keyCode !== 40) { return; } - let option = this.options[index]; - this.options[index] = this.options[index + 1]; - this.options[index + 1] = option; - this.$forceUpdate(); + + e.preventDefault(); + + const moveUp = e.keyCode === 38; + + this.moveElement(index, moveUp ? -1 : 1).then((newIndex) => { + this.assistiveLive = this.$gettextInterpolate( + this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'), + {pos: newIndex + 1, listLength: this.options.length} + ); + + this.$nextTick(() => { + this.$refs['draghandle'][newIndex].focus(); + }); + }) }, - moveUp: function (index) { - if (index === 0) { - return; + moveElement(index, direction) { + if (this.options[index + direction] === undefined) { + return Promise.resolve(index); } - let option = this.options[index]; - this.options[index] = this.options[index - 1]; - this.options[index - 1] = option; - this.$forceUpdate(); + + const indices = [index, index + direction].sort(); + + this.options.splice( + Math.min(...indices), + 2, + ...indices.reverse().map(idx => this.options[idx]) + ); + + return Promise.resolve(index + direction); } }, - mounted: function () { - this.options = this.value; - this.unique_id = 'array_input_' + Math.floor(Math.random() * 100000000); - }, watch: { - options (new_data, old_data) { - if (typeof old_data === 'undefined' || typeof new_data === 'undefined') { - return; - } - this.$emit('input', new_data); + options: { + handler(current) { + this.$emit('input', current); + }, + deep: true }, - value (new_val) { - this.options = new_val; + value: { + handler(current) { + this.options = current; + }, + immediate: true } } } </script> +<style scoped> +.input-array input[type="text"] { + max-width: unset; +} +</style> diff --git a/resources/vue/components/questionnaires/LikertEdit.vue b/resources/vue/components/questionnaires/LikertEdit.vue index c87f9fe..736be6b 100644 --- a/resources/vue/components/questionnaires/LikertEdit.vue +++ b/resources/vue/components/questionnaires/LikertEdit.vue @@ -1,66 +1,27 @@ <template> <div class="likert_edit"> - <div class="formpart" tabindex="0" ref="autofocus"> {{ $gettext('Einleitungstext' )}} - <studip-wysiwyg v-model="val_clone.description"></studip-wysiwyg> + <StudipWysiwyg v-model="val_clone.description" /> </div> - <span aria-live="assertive" class="sr-only">{{ assistiveLive }}</span> + <InputArray v-model="val_clone.statements" + :label="$gettext('Aussage')" + :label-plural="$gettext('Aussagen')" + :additional-colspan="val_clone.options.length" + > + <template #header-cells> + <th v-for="(option, index) in val_clone.options" class="option-cell" :key="index"> + {{ option }} + </th> + </template> - <table class="default nohover"> - <thead> - <tr> - <th class="dragcolumn"></th> - <th>{{ $gettext('Aussagen') }}</th> - <th v-for="(option, index) in val_clone.options" :key="index">{{ option }}</th> - <th class="actions"></th> - </tr> - </thead> - <draggable v-model="val_clone.statements" handle=".dragarea" tag="tbody" class="statements"> - <tr v-for="(statement, index) in val_clone.statements" :key="index"> - <td class="dragcolumn"> - <a class="dragarea" - tabindex="0" - :title="$gettextInterpolate($gettext('Sortierelement für Aussage %{statement}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {statement: statement})" - @keydown="keyHandler($event, index)" - :ref="'draghandle_' + index"> - <span class="drag-handle"></span> - </a> - </td> - <td> - <input type="text" - :ref="'statement_' + index" - :placeholder="$gettext('Aussage')" - @paste="(ev) => onPaste(ev, index)" - v-model="val_clone.statements[index]"> - </td> - <td v-for="(option, index2) in val_clone.options" :key="index2"> - <input type="radio" disabled :title="option"> - </td> - <td class="actions"> - <studip-icon name="delete" - shape="trash" - :size="20" - @click.prevent="deleteStatement(index)" - :title="$gettext('Aussage löschen')" - ></studip-icon> - </td> - </tr> - </draggable> - <tfoot> - <tr> - <td :colspan="val_clone.options.length + 3"> - <studip-icon name="add" - shape="add" - :size="20" - @click.prevent="addStatement()" - :title="$gettext('Aussage hinzufügen')" - ></studip-icon> - </td> - </tr> - </tfoot> - </table> + <template #body-cells> + <td v-for="(option, index) in val_clone.options" class="option-cell" :key="index"> + <input type="radio" disabled :title="option"> + </td> + </template> + </InputArray> <label> <input type="checkbox" v-model.number="val_clone.mandatory" true-value="1" false-value="0"> @@ -73,17 +34,18 @@ <div> <div>{{ $gettext('Antwortmöglichkeiten konfigurieren') }}</div> - <input-array v-model="val_clone.options"></input-array> + <InputArray v-model="val_clone.options" /> </div> </div> </template> <script> -import draggable from 'vuedraggable'; -import InputArray from "./InputArray.vue"; import { $gettext } from '../../../assets/javascripts/lib/gettext'; +import InputArray from "./InputArray.vue"; +import { QuestionnaireComponent } from '../../mixins/QuestionnaireComponent'; -const default_value = () => ({ +// This is necesssar since $gettext does not seem to work in data() or created() +const default_values = () => ({ description: '', statements: ['', '', '', ''], mandatory: 0, @@ -96,115 +58,16 @@ const default_value = () => ({ $gettext('trifft nicht zu'), ], }); + export default { name: 'likert-edit', - components: { - draggable, - InputArray - }, - props: { - value: { - type: Object, - required: false, - default() { - return {...default_value()}; - } - }, - question_id: { - type: String, - required: false - } - }, - data() { - return { - val_clone: null, - assistiveLive: '' - }; - }, - methods: { - addStatement(val = '', position = null) { - if (position === null) { - this.val_clone.statements.push(val || ''); - } else { - this.val_clone.statements.splice(position, 0, val || ''); - } - this.$nextTick(() => { - this.$refs['statement_' + (this.val_clone.statements.length - 1)][0].focus(); - }); - }, - deleteStatement(index) { - STUDIP.Dialog.confirm(this.$gettext('Wirklich löschen?')).done(() => { - this.$delete(this.val_clone.statements, index); - }); - }, - onPaste(ev, position) { - let data = ev.clipboardData.getData("text").split("\n"); - for (let i = 0; i < data.length; i++) { - if (data[i].trim()) { - this.addStatement(data[i], position + i); - } - } - }, - keyHandler(e, index) { - switch (e.keyCode) { - case 38: // up - e.preventDefault(); - if (index > 0) { - this.moveUp(index); - this.$nextTick(() => { - this.$refs['draghandle_' + (index - 1)][0].focus(); - this.assistiveLive = this.$gettextInterpolate( - this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'), - {pos: index, listLength: this.val_clone.statements.length} - ); - }); - } - break; - case 40: // down - e.preventDefault(); - if (index < this.val_clone.statements.length - 1) { - this.moveDown(index); - this.$nextTick(() => { - this.$refs['draghandle_' + (index + 1)][0].focus(); - this.assistiveLive = this.$gettextInterpolate( - this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'), - {pos: index + 2, listLength: this.val_clone.statements.length} - ); - }); - } - break; - } - }, - moveDown(index) { - this.val_clone.statements.splice( - index, - 2, - this.val_clone.statements[index + 1], - this.val_clone.statements[index] - ) - }, - moveUp(index) { - this.val_clone.statements.splice( - index - 1, - 2, - this.val_clone.statements[index], - this.val_clone.statements[index - 1] - ) - } - }, + components: { InputArray }, + mixins: [ QuestionnaireComponent ], created() { - this.val_clone = Object.assign({}, default_value(), this.value ?? {}); + this.setDefaultValues(default_values()); }, mounted() { this.$refs.autofocus.focus(); - }, - watch: { - val_clone: { - handler(current) { - this.$emit('input', current); - }, - deep: true - } } } </script> diff --git a/resources/vue/components/questionnaires/QuestionnaireInfoEdit.vue b/resources/vue/components/questionnaires/QuestionnaireInfoEdit.vue index bc5e829..83d5fa2 100644 --- a/resources/vue/components/questionnaires/QuestionnaireInfoEdit.vue +++ b/resources/vue/components/questionnaires/QuestionnaireInfoEdit.vue @@ -8,39 +8,26 @@ <div class="formpart"> {{ $gettext('Hinweistext (optional)') }} - <studip-wysiwyg v-model="val_clone.description" :key="question_id"></studip-wysiwyg> + <StudipWysiwyg v-model="val_clone.description" /> </div> </div> </template> <script> -import StudipWysiwyg from "../StudipWysiwyg.vue"; +import { QuestionnaireComponent } from '../../mixins/QuestionnaireComponent'; export default { name: 'questionnaire-info-edit', - components: { - StudipWysiwyg + mixins: [ QuestionnaireComponent ], + created() { + this.setDefaultValues({ + url: '', + description: '' + }); }, - props: { - value: { - type: Object, - required: false, - default() { - return { - url: '', - description: '' - }; - } - }, - question_id: { - type: String, - required: false - } - }, - data () { - return { - val_clone: this.value, - }; + mounted() { + this.$refs.infoUrl.focus(); + this.checkValidity(); }, methods: { checkValidity() { @@ -53,15 +40,6 @@ export default { this.$refs.infoUrl.reportValidity(); } } - }, - mounted() { - this.$refs.infoUrl.focus(); - this.checkValidity(); - }, - watch: { - value (new_val) { - this.val_clone = new_val; - } } } </script> diff --git a/resources/vue/components/questionnaires/RangescaleEdit.vue b/resources/vue/components/questionnaires/RangescaleEdit.vue index 91aec1c..cd7ce3b 100644 --- a/resources/vue/components/questionnaires/RangescaleEdit.vue +++ b/resources/vue/components/questionnaires/RangescaleEdit.vue @@ -3,68 +3,25 @@ <div class="formpart" tabindex="0" ref="autofocus"> {{ $gettext('Einleitungstext') }} - <studip-wysiwyg v-model="val_clone.description"></studip-wysiwyg> + <StudipWysiwyg v-model="val_clone.description" /> </div> - <span aria-live="assertive" class="sr-only">{{ assistiveLive }}</span> - - <table class="default nohover"> - <thead> - <tr> - <th class="dragcolumn"></th> - <th>{{ $gettext('Aussagen') }}</th> - <th v-for="i in (val_clone.maximum - val_clone.minimum + 1)" :key="i" class="number">{{ (val_clone.minimum - 1 + i) }}</th> - <th v-if="val_clone.alternative_answer.trim().length > 0">{{ val_clone.alternative_answer }}</th> - <th class="actions"></th> - </tr> - </thead> - <draggable v-model="val_clone.statements" handle=".dragarea" tag="tbody" class="statements"> - <tr v-for="(statement, index) in val_clone.statements" :key="index"> - <td class="dragcolumn"> - <a class="dragarea" - tabindex="0" - :title="$gettextInterpolate($gettext('Sortierelement für Aussage %{statement}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {statement: statement})" - @keydown="keyHandler($event, index)" - :ref="'draghandle_' + index"> - <span class="drag-handle"></span> - </a> - </td> - <td> - <input type="text" - :ref="'statement_' + index" - :placeholder="$gettext('Aussage')" - @paste="(ev) => onPaste(ev, index)" - v-model="val_clone.statements[index]"> - </td> - <td v-for="i in (val_clone.maximum - val_clone.minimum + 1)" :key="i"> - <input type="radio" disabled :title="i + val_clone.minimum - 1"> - </td> - <td v-if="val_clone.alternative_answer.trim().length > 0"> - <input type="radio" disabled :title="val_clone.alternative_answer"> - </td> - <td class="actions"> - <studip-icon name="delete" - shape="trash" - :size="20" - @click.prevent="deleteStatement(index)" - :title="$gettext('Aussage löschen')" - ></studip-icon> - </td> - </tr> - </draggable> - <tfoot> - <tr> - <td :colspan="val_clone.maximum - val_clone.minimum + 4 + (val_clone.alternative_answer.trim().length > 0 ? 1 : 0)"> - <studip-icon name="add" - shape="add" - :size="20" - @click.prevent="addStatement()" - :title="$gettext('Aussage hinzufügen')" - ></studip-icon> - </td> - </tr> - </tfoot> - </table> + <InputArray v-model="val_clone.statements" + :label="$gettext('Aussage')" + :label-plural="$gettext('Aussagen')" + :additional-colspan="options.length" + > + <template #header-cells> + <th v-for="(option, index) in options" class="option-cell" :key="index"> + {{ option }} + </th> + </template> + <template #body-cells> + <td v-for="(option, index) in options" class="option-cell" :key="index"> + <input type="radio" disabled :title="option"> + </td> + </template> + </InputArray> <label> <input type="checkbox" v-model.number="val_clone.mandatory" true-value="1" false-value="0"> @@ -82,135 +39,48 @@ <label> {{ $gettext('Minimum') }} - <input type="number" v-model.number="val_clone.minimum" min="1"> + <input type="number" v-model.number="val_clone.minimum" min="1" :max="val_clone.maximum"> </label> <label> {{ $gettext('Ausweichantwort (leer lassen für keine)') }} - <input type="text" v-model="val_clone.alternative_answer"> + <input type="text" v-model.trim="val_clone.alternative_answer"> </label> </div> </template> <script> -import draggable from 'vuedraggable'; +import InputArray from './InputArray.vue'; +import { QuestionnaireComponent } from '../../mixins/QuestionnaireComponent'; -const default_value = () => ({ - description: '', - statements: ['', '', '', ''], - mandatory: 0, - randomize: 0, - minimum: 1, - maximum: 5, - alternative_answer: '' -}); export default { - name: 'likert-edit', - components: { - draggable, - }, - props: { - value: { - type: Object, - required: false, - default() { - return default_value(); - } - }, - question_id: { - type: String, - required: false - } - }, - data() { - return { - val_clone: null, - assistiveLive: '' - }; - }, - methods: { - addStatement(val = '', position = null) { - if (position === null) { - this.val_clone.statements.push(val || ''); - } else { - this.val_clone.statements.splice(position, 0, val || ''); - } - this.$nextTick(() => { - this.$refs['statement_' + (this.value.statements.length - 1)][0].focus(); - }); - }, - deleteStatement(index) { - STUDIP.Dialog.confirm(this.$gettext('Wirklich löschen?')).done(() => { - this.$delete(this.value.statements, index); - }); - }, - onPaste(ev, position) { - let data = ev.clipboardData.getData('text').split("\n"); - for (let i = 0; i < data.length; i++) { - if (data[i].trim()) { - this.addStatement(data[i], position + i); - } - } - }, - keyHandler(e, index) { - switch (e.keyCode) { - case 38: // up - e.preventDefault(); - if (index > 0) { - this.moveUp(index); - this.$nextTick(() => { - this.$refs['draghandle_' + (index - 1)][0].focus(); - this.assistiveLive = this.$gettextInterpolate( - this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'), - {pos: index, listLength: this.val_clone.statements.length} - ); - }); - } - break; - case 40: // down - e.preventDefault(); - if (index < this.val_clone.statements.length - 1) { - this.moveDown(index); - this.$nextTick(() => { - this.$refs['draghandle_' + (index + 1)][0].focus(); - this.assistiveLive = this.$gettextInterpolate( - this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'), - {pos: index + 2, listLength: this.val_clone.statements.length} - ); - }); - } - break; - } - }, - moveDown(index) { - this.val_clone.statements.splice( - index, - 2, - this.val_clone.statements[index + 1], - this.val_clone.statements[index] - ); - }, - moveUp(index) { - this.val_clone.statements.splice( - index - 1, - 2, - this.val_clone.statements[index], - this.val_clone.statements[index - 1] - ); - }, - }, + name: 'rangescale-edit', + components: { InputArray }, + mixins: [ QuestionnaireComponent ], created() { - this.val_clone = Object.assign({}, default_value(), this.value ?? {}); + this.setDefaultValues({ + alternative_answer: '', + description: '', + mandatory: 0, + maximum: 5, + minimum: 1, + randomize: 0, + statements: ['', '', '', ''] + }); }, mounted() { this.$refs.autofocus.focus(); }, - watch: { - val_clone: { - handler(current) { - this.$emit('input', current); - }, - deep: true + computed: { + options() { + let result = []; + for (let i = this.val_clone.minimum; i <= this.val_clone.maximum; i += 1) { + result.push(i); + } + if (this.val_clone.alternative_answer.length > 0) { + result.push(this.val_clone.alternative_answer); + } + return result; } } } diff --git a/resources/vue/components/questionnaires/VoteEdit.vue b/resources/vue/components/questionnaires/VoteEdit.vue index 9acb01f..1d6d9cf 100644 --- a/resources/vue/components/questionnaires/VoteEdit.vue +++ b/resources/vue/components/questionnaires/VoteEdit.vue @@ -2,10 +2,10 @@ <div class="vote_edit"> <div class="formpart" tabindex="0" ref="autofocus"> {{ $gettext('Frage') }} - <studip-wysiwyg v-model="val_clone.description" :key="question_id"></studip-wysiwyg> + <StudipWysiwyg v-model="val_clone.description" /> </div> - <input-array v-model="val_clone.options"></input-array> + <InputArray v-model="val_clone.options" /> <label> <input type="checkbox" v-model.number="val_clone.multiplechoice" true-value="1" false-value="0"> @@ -24,47 +24,24 @@ </template> <script> -import StudipWysiwyg from "../StudipWysiwyg.vue"; import InputArray from "./InputArray.vue"; +import { QuestionnaireComponent } from '../../mixins/QuestionnaireComponent'; export default { name: 'vote-edit', - components: { - StudipWysiwyg, - InputArray + components: { InputArray }, + mixins: [QuestionnaireComponent], + created() { + this.setDefaultValues({ + description: '', + mandatory: '0', + multiplechoice: '1', + options: ['', '', '', ''], + randomize: '0', + }); }, - props: { - value: { - type: Object, - required: false, - default: function () { - return {}; - } - }, - question_id: { - type: String, - required: false - } - }, - data: function () { - return { - val_clone: {} - }; - }, - mounted: function () { - this.val_clone = this.value; - if (!this.value.description) { - this.$emit('input', { - multiplechoice: 1, - options: ['', '', '', ''] - }); - } + mounted() { this.$refs.autofocus.focus(); - }, - watch: { - value (new_val) { - this.val_clone = new_val; - } } } </script> diff --git a/resources/vue/components/responsive/ResponsiveContentBar.vue b/resources/vue/components/responsive/ResponsiveContentBar.vue index d64ac52..eb6dd96 100644 --- a/resources/vue/components/responsive/ResponsiveContentBar.vue +++ b/resources/vue/components/responsive/ResponsiveContentBar.vue @@ -131,7 +131,11 @@ export default { .classList.add('contentbar-wrapper-right'); } - document.getElementById('responsive-contentbar-container').prepend(this.realContentbar); + const contentbarContainer = document.getElementById('responsive-contentbar-container'); + + contentbarContainer.prepend(this.realContentbar); + + document.getElementById('content-wrapper').style.marginTop = `${contentbarContainer.clientHeight}px`; } else { this.realContentbar.id = 'contentbar'; document.getElementById('toggle-sidebar').remove(); @@ -145,6 +149,8 @@ export default { } document.querySelector(this.realContentbarSource).prepend(this.realContentbar); + + document.getElementById('content-wrapper').style.marginTop = 'initial'; } } }, diff --git a/resources/vue/components/responsive/ResponsiveNavigation.vue b/resources/vue/components/responsive/ResponsiveNavigation.vue index 1c07e73..d9c391d 100644 --- a/resources/vue/components/responsive/ResponsiveNavigation.vue +++ b/resources/vue/components/responsive/ResponsiveNavigation.vue @@ -130,10 +130,6 @@ export default { type: String, default: '' }, - hasSidebar: { - type: Boolean, - default: true - }, navigation: { type: Object, required: true, @@ -162,6 +158,7 @@ export default { classObserver: null, dialogObserver: null, hasSkiplinks: document.querySelector('#skiplink_list') !== null, + hasSidebar: false, hasContentbar: false, contentbarTitle: '' } @@ -494,6 +491,8 @@ export default { } }, mounted() { + this.hasSidebar = document.querySelectorAll('#sidebar .sidebar-widget:not(#sidebar-navigation)').length > 0; + const cache = STUDIP.Cache.getInstance('responsive.'); const fullscreen = cache.get('fullscreen-mode') ?? false; const fullscreenDocument = document.documentElement.classList.contains('fullscreen-mode'); @@ -564,18 +563,6 @@ export default { attributeFilter: ['class'] }); - // Check for closed dialog, re-mounting the Vue component. - this.dialogObserver = new MutationObserver(mutations => { - if (mutations[0].removedNodes.length > 0 && - mutations[0].removedNodes[0].classList.contains('ui-widget-overlay')) { - document.getElementById('responsive-menu').replaceChildren(this.$el); - } - }); - - this.dialogObserver.observe(document.body, { - childList: true - }); - this.globalOn('has-contentbar', value => { this.hasContentbar = value; if (value && this.isFullscreen) { @@ -594,6 +581,9 @@ export default { attributeFilter: ['class'] }) }); + + // Check initial state after load + this.headerMagic = document.querySelector('body').classList.contains('fixed'); }, beforeDestroy() { this.classObserver.disconnect(); diff --git a/resources/vue/components/tree/StudipTree.vue b/resources/vue/components/tree/StudipTree.vue index 136fc95..fc6dc41 100644 --- a/resources/vue/components/tree/StudipTree.vue +++ b/resources/vue/components/tree/StudipTree.vue @@ -33,14 +33,21 @@ <MountingPortal v-if="withSearch" mountTo="#search-widget" name="sidebar-search"> <search-widget v-if="currentNode" :min-length="3" ref="searchWidget"></search-widget> </MountingPortal> + <MountingPortal v-if="!editable && !isSearching && !isLoading && currentNode" + mountTo="#views-widget" + name="sidebar-views"> + <studip-tree-view-widget :config="viewConfig" /> + </MountingPortal> </div> </template> <script> import axios from 'axios'; import { TreeMixin } from '../../mixins/TreeMixin'; +import PageLayout from '../../../assets/javascripts/lib/page_layout'; import StudipProgressIndicator from '../StudipProgressIndicator.vue'; import SearchWidget from '../SearchWidget.vue'; +import StudipTreeViewWidget from './StudipTreeViewWidget.vue'; import StudipTreeList from './StudipTreeList.vue'; import StudipTreeTable from './StudipTreeTable.vue'; import StudipTreeNode from './StudipTreeNode.vue'; @@ -49,7 +56,13 @@ import TreeSearchResult from './TreeSearchResult.vue'; export default { name: 'StudipTree', components: { - TreeSearchResult, SearchWidget, StudipProgressIndicator, StudipTreeList, StudipTreeTable, StudipTreeNode + TreeSearchResult, + SearchWidget, + StudipTreeViewWidget, + StudipProgressIndicator, + StudipTreeList, + StudipTreeTable, + StudipTreeNode }, mixins: [ TreeMixin ], props: { @@ -163,12 +176,21 @@ export default { isLoading: false, showStructuralNavigation: false, searchConfig: {}, - isSearching: false + isSearching: false, + viewConfig: null, + pageTitle: document.title } }, methods: { changeCurrentNode(node) { this.currentNode = node; + this.viewConfig = { + view: this.viewType, + node: this.currentNode, + semester: this.semester, + semClass: this.semClass + }; + this.setPageTitle(this.currentNode.attributes.name); this.$nextTick(() => { document.getElementById('tree-breadcrumb-' + node.attributes.id)?.focus(); }); @@ -187,6 +209,10 @@ export default { form.appendChild(input); } input.setAttribute('value', searchterm); + }, + setPageTitle(nodeTitle) { + const title = this.pageTitle.split('-'); + PageLayout.title = title.slice(0, -1).join('-') + '/ ' + nodeTitle + ' -' + title[title.length - 1]; } }, mounted() { @@ -206,6 +232,13 @@ export default { this.currentNode = this.startNode; this.loaded = true; this.isLoading = false; + this.viewConfig = { + view: this.viewType, + node: this.currentNode, + semester: this.semester, + semClass: this.semClass + }; + this.setPageTitle(this.currentNode.attributes.name); }); axios.interceptors.request.eject(loadingIndicator); diff --git a/resources/vue/components/tree/StudipTreeList.vue b/resources/vue/components/tree/StudipTreeList.vue index 4ba08bc..6214234 100644 --- a/resources/vue/components/tree/StudipTreeList.vue +++ b/resources/vue/components/tree/StudipTreeList.vue @@ -15,7 +15,7 @@ <a v-if="editable && currentNode.attributes.id !== 'root'" :href="editUrl + '/' + currentNode.attributes.id" @click.prevent="editNode(editUrl, currentNode.id)" data-dialog="size=medium" - :title="$gettextInterpolate($gettext('%{name} bearbeiten'), {name: currentNode.attributes.name})"> + :title="$gettextInterpolate($gettext('%{name} bearbeiten'), {name: currentNode.attributes.name}, true)"> <studip-icon shape="edit" :size="20"></studip-icon> </a> @@ -36,7 +36,7 @@ <li v-for="(child, index) in children" :key="index" class="studip-tree-child"> <a v-if="editable && children.length > 1" class="drag-link" tabindex="0" - :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name})" + :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name}, true)" @keydown="keyHandler($event, index)" :ref="'draghandle-' + index"> <span class="drag-handle"></span> @@ -91,9 +91,12 @@ <tbody> <tr v-for="(course) in courses" :key="course.id" class="studip-tree-child studip-tree-course"> <td> - <a :href="courseUrl(course.id)" - :title="$gettextInterpolate($gettext('Zur Veranstaltung %{ course }'), - { course: course.attributes.title })"> + <a :href="courseUrl(course.id)" tabindex="0" + :title="$gettextInterpolate( + $gettext('Zur Veranstaltung %{ title }'), + { title: course.attributes.title }, + true + )"> <studip-icon shape="seminar" :size="26"></studip-icon> <template v-if="course.attributes['course-number']"> {{ course.attributes['course-number'] }} diff --git a/resources/vue/components/tree/StudipTreeTable.vue b/resources/vue/components/tree/StudipTreeTable.vue index 03a9d7b..093dd9c 100644 --- a/resources/vue/components/tree/StudipTreeTable.vue +++ b/resources/vue/components/tree/StudipTreeTable.vue @@ -79,7 +79,7 @@ <td> <a v-if="editable && children.length > 1" class="drag-link" role="option" tabindex="0" - :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name})" + :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name}, true)" @keydown="keyHandler($event, index)" :ref="'draghandle-' + index"> <span class="drag-handle"></span> @@ -93,7 +93,7 @@ <a :href="nodeUrl(child.id, semester !== 'all' ? semester : null)" tabindex="0" @click.prevent="openNode(child)" :title="$gettextInterpolate($gettext('Unterebene %{ node } öffnen'), - { node: node.attributes.name })"> + { node: node.attributes.name }, true)"> {{ child.attributes.name }} </a> </td> @@ -112,8 +112,11 @@ </td> <td> <a :href="courseUrl(course.id)" tabindex="0" - :title="$gettextInterpolate($gettext('Zur Veranstaltung %{ course }'), - { course: course.attributes.title })"> + :title="$gettextInterpolate( + $gettext('Zur Veranstaltung %{ title }'), + { title: course.attributes.title }, + true + )"> <template v-if="course.attributes['course-number']"> {{ course.attributes['course-number'] }} </template> diff --git a/resources/vue/components/tree/StudipTreeViewWidget.vue b/resources/vue/components/tree/StudipTreeViewWidget.vue new file mode 100644 index 0000000..30e2d5c --- /dev/null +++ b/resources/vue/components/tree/StudipTreeViewWidget.vue @@ -0,0 +1,56 @@ +<template> + <sidebar-widget id="views-widget" class="sidebar-views" :title="$gettext('Ansicht')"> + <template #content> + <ul class="widget-list widget-links sidebar-views"> + <li :class="{ active: config.view === 'list' }"> + <a :href="getUrl('list')" + :title="$gettext('Verzeichnis als Liste anzeigen')" + tabindex="0"> + {{ $gettext('Als Liste anzeigen') }} + </a> + </li> + <li :class="{ active: config.view === 'table' }"> + <a :href="getUrl('table')" + :title="$gettext('Verzeichnis als Tabelle anzeigen')" + tabindex="0"> + {{ $gettext('Als Tabelle anzeigen') }} + </a> + </li> + </ul> + </template> + </sidebar-widget> +</template> + +<script> +import SidebarWidget from '../SidebarWidget.vue'; + +export default { + name: 'StudipTreeViewWidget', + components: { + SidebarWidget + }, + props: { + config: { + type: Object, + required: true + } + }, + methods: { + getUrl(showAs) { + const url = new URL(window.location); + url.searchParams.set('show_as', showAs); + url.searchParams.set('node_id', this.config.node.id); + + if (this.config.semester !== '') { + url.searchParams.set('semester', this.config.semester); + } + + if (this.config.semClass !== 0) { + url.searchParams.set('semclass', this.config.semClass); + } + + return url.toString(); + } + } +} +</script> diff --git a/resources/vue/components/tree/TreeBreadcrumb.vue b/resources/vue/components/tree/TreeBreadcrumb.vue index 33b04b3..a8c3dd5 100644 --- a/resources/vue/components/tree/TreeBreadcrumb.vue +++ b/resources/vue/components/tree/TreeBreadcrumb.vue @@ -10,7 +10,7 @@ <a :href="nodeUrl(ancestor.classname + '_' + ancestor.id)" :ref="ancestor.id" @click.prevent="openNode(ancestor.id, ancestor.classname)" tabindex="0" :id="'tree-breadcrumb-' + ancestor.id" - :title="$gettextInterpolate($gettext('%{ node } öffnen'), { node: ancestor.name})"> + :title="$gettextInterpolate($gettext('%{ node } öffnen'), { node: ancestor.name}, true)"> {{ ancestor.name }} </a> <template v-if="index !== node.attributes.ancestors.length - 1"> diff --git a/resources/vue/components/tree/TreeCourseDetails.vue b/resources/vue/components/tree/TreeCourseDetails.vue index 609349d..020cc33 100644 --- a/resources/vue/components/tree/TreeCourseDetails.vue +++ b/resources/vue/components/tree/TreeCourseDetails.vue @@ -4,14 +4,16 @@ ({{ details.semester }}) </div> <div class="admission-state" v-if="details.admissionstate"> - <studip-icon :shape="details.admissionstate.icon" :role="details.admissionstate.role" - :title="details.admissionstate.info"></studip-icon> + <studip-icon :shape="details.admissionstate.icon" + :role="details.admissionstate.role" + :alt="details.admissionstate.info"></studip-icon> </div> <div class="course-lecturers"> <span v-for="(lecturer, index) in details.lecturers" :key="index"> <a :href="profileUrl(lecturer.username)" :title="$gettextInterpolate($gettext('Zum Profil von %{ user }'), - { user: lecturer.name })"> + { user: lecturer.name }, true)" + tabindex="0"> {{ lecturer.name }} </a><template v-if="details.lecturers.length > 1 && index < details.lecturers.length - 1">, </template> </span> diff --git a/resources/vue/components/tree/TreeNodeCoursePath.vue b/resources/vue/components/tree/TreeNodeCoursePath.vue index 26ab88e..71f69ea 100644 --- a/resources/vue/components/tree/TreeNodeCoursePath.vue +++ b/resources/vue/components/tree/TreeNodeCoursePath.vue @@ -1,6 +1,12 @@ <template> <div> - <studip-icon shape="info-circle" @click="togglePathInfo"></studip-icon> + <button type="button" + @click.prevent="togglePathInfo" + :title="showPaths + ? $gettext('Pfad im Verzeichnis ausblenden') + : $gettext('Pfad im Verzeichnis anzeigen')"> + <studip-icon shape="info-circle"></studip-icon> + </button> <ul v-if="showPaths" class="studip-tree-course-path"> <li v-for="(path, pindex) in paths" :key="pindex"> <button @click.prevent="openNode(path[path.length - 1].id)"> diff --git a/resources/vue/components/tree/TreeNodeTile.vue b/resources/vue/components/tree/TreeNodeTile.vue index dab2bdf..698cc25 100644 --- a/resources/vue/components/tree/TreeNodeTile.vue +++ b/resources/vue/components/tree/TreeNodeTile.vue @@ -1,6 +1,6 @@ <template> <a :href="url" @click.prevent="openNode" :title="$gettextInterpolate($gettext('Unterebene %{ node } öffnen'), - { node: node.attributes.name })"> + { node: node.attributes.name }, true)"> <p class="studip-tree-child-title"> {{ node.attributes.name }} </p> diff --git a/resources/vue/components/tree/TreeSearchResult.vue b/resources/vue/components/tree/TreeSearchResult.vue index e5093eb..9799dae 100644 --- a/resources/vue/components/tree/TreeSearchResult.vue +++ b/resources/vue/components/tree/TreeSearchResult.vue @@ -31,13 +31,14 @@ </td> <td> <a :href="courseUrl(course.id)" - :title="$gettextInterpolate($gettext('Zur Veranstaltung %{name}'), {name: + course.attributes.title})"> + :title="$gettextInterpolate($gettext('Zur Veranstaltung %{title}'), {title: course.attributes.title}, true)" + tabindex="0"> <template v-if="course.attributes['course-number']"> {{ course.attributes['course-number'] }} </template> {{ course.attributes.title }} - <div :id="'course-dates-' + course.id" class="course-dates"></div> </a> + <div :id="'course-dates-' + course.id" class="course-dates"></div> <tree-node-course-path :node-class="searchConfig.classname" :course-id="course.id"></tree-node-course-path> </td> |
