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 | |
| parent | da0022e5c1abbf9825ae76debaabdff7e8623bb4 (diff) | |
| parent | 97a188592c679890a25c37ab78463add76a52ff7 (diff) | |
Merge branch 'main' into issue-3911issue-3911
Diffstat (limited to 'resources')
152 files changed, 3288 insertions, 2336 deletions
diff --git a/resources/assets/javascripts/bootstrap/application.js b/resources/assets/javascripts/bootstrap/application.js index a9f53df..4ad248f 100644 --- a/resources/assets/javascripts/bootstrap/application.js +++ b/resources/assets/javascripts/bootstrap/application.js @@ -353,6 +353,7 @@ jQuery(document).on('click', 'a[data-behaviour~="ajax-toggle"]', function (event (function ($) { $(document).on('click', 'form[name=course-details] fieldset legend', function () { $('#open_variable').attr('value', $(this).parent('fieldset').data('open')); + $(this).parent('fieldset').attr('aria-expanded', $(this).parent('fieldset').attr('aria-expanded') == 'true' ? 'false' : 'true'); }); }(jQuery)); diff --git a/resources/assets/javascripts/bootstrap/article.js b/resources/assets/javascripts/bootstrap/article.js index fbfc131..04316f5 100644 --- a/resources/assets/javascripts/bootstrap/article.js +++ b/resources/assets/javascripts/bootstrap/article.js @@ -13,6 +13,7 @@ // Open the contentbox article.toggleClass('open').removeClass('new'); + article.attr('aria-expanded', article.attr('aria-expanded') === 'true' ? 'false' : 'true'); }); // Open closed article contents when location hash matches diff --git a/resources/assets/javascripts/bootstrap/clipboard.js b/resources/assets/javascripts/bootstrap/clipboard.js index e525b35..a64a605 100644 --- a/resources/assets/javascripts/bootstrap/clipboard.js +++ b/resources/assets/javascripts/bootstrap/clipboard.js @@ -25,7 +25,9 @@ STUDIP.domReady(function () { jQuery(document).on('click', '.clipboard-remove-button', function (event) { event.preventDefault(); - STUDIP.Dialog.confirm($(this).data('confirm-message'), function() { + + const message = $(this).data('confirm-message'); + STUDIP.Dialog.confirm(message).done(() => { STUDIP.Clipboard.handleRemoveClick(event.target); }); }); @@ -62,10 +64,11 @@ STUDIP.domReady(function () { }); }); - jQuery(document).on('submit', '.clipboard-widget .new-clipboard-form', function (event) { - event.preventDefault(); - STUDIP.Clipboard.handleAddForm(event); - }); + jQuery(document).on( + 'submit', + '.clipboard-widget .new-clipboard-form', + STUDIP.Clipboard.handleAddForm + ); jQuery(document).on('click', '.clipboard-add-item-button', function (event) { event.preventDefault(); diff --git a/resources/assets/javascripts/bootstrap/consultations.js b/resources/assets/javascripts/bootstrap/consultations.js index ef79d9c..51ffa85 100644 --- a/resources/assets/javascripts/bootstrap/consultations.js +++ b/resources/assets/javascripts/bootstrap/consultations.js @@ -10,9 +10,9 @@ $(document).on('click', '.consultation-delete-check:not(.ignore)', event => { } let requests = ids.map(id => { - return STUDIP.jsonapi.GET(`consultation-slots/${id}/bookings`).then(result => result.data.length); + return STUDIP.jsonapi.withPromises().get(`consultation-slots/${id}/bookings`).then(response => response.data.length); }); - $.when(...requests).done((...results) => { + Promise.all(requests).then((...results) => { if (results.some(result => result > 0)) { $(event.target).addClass('ignore').click().removeClass('ignore'); } else { diff --git a/resources/assets/javascripts/bootstrap/contentbox.js b/resources/assets/javascripts/bootstrap/contentbox.js index 42c5df1..3f05331 100644 --- a/resources/assets/javascripts/bootstrap/contentbox.js +++ b/resources/assets/javascripts/bootstrap/contentbox.js @@ -13,5 +13,6 @@ $(document).on('click', 'section.contentbox article header h1 a', function(e) { // Open the contentbox article.toggleClass('open').removeClass('new'); + article.attr('aria-expanded', article.attr('aria-expanded') === 'true' ? 'false' : 'true'); } }); diff --git a/resources/assets/javascripts/bootstrap/copyable_links.js b/resources/assets/javascripts/bootstrap/copyable_links.js index 521eae4..b4f6fc6 100644 --- a/resources/assets/javascripts/bootstrap/copyable_links.js +++ b/resources/assets/javascripts/bootstrap/copyable_links.js @@ -16,24 +16,8 @@ $(document).on('click', 'a.copyable-link', function (event) { document.execCommand('Copy'); dummy.remove(); - // Show visual hint using a deferred (this way we don't need to - // duplicate the functionality in the done() handler) - (new Promise((resolve, reject) => { - let confirmation = $('<div class="copyable-link-confirmation copyable-link-success">'); - confirmation.text($gettext('Link wurde kopiert')); - confirmation.insertBefore('#content'); - - // Resolve deferred when animation has ended or after 2 seconds as a - // fail safe - let timeout = setTimeout(() => { - $(this).parent().off('animationend'); - resolve(confirmation); - }, 1500); - $(this).parent().one('animationend', () => { - clearTimeout(timeout); - resolve(confirmation); - }); - })).then((confirmation, parent) => { - confirmation.remove(); - }); + STUDIP.eventBus.emit( + 'push-system-notification', + { type: 'success', message: $gettext('Link wurde kopiert') } + ); }); diff --git a/resources/assets/javascripts/bootstrap/forms.js b/resources/assets/javascripts/bootstrap/forms.js index c34a0c1..7643612 100644 --- a/resources/assets/javascripts/bootstrap/forms.js +++ b/resources/assets/javascripts/bootstrap/forms.js @@ -1,4 +1,5 @@ import { $gettext, $gettextInterpolate } from '../lib/gettext'; +import Report from '../lib/report.js'; // Allow fieldsets to collapse $(document).on( @@ -291,9 +292,12 @@ STUDIP.ready(function () { url: v.STUDIPFORM_AUTOSAVEURL, data: params, type: 'post', - success() { - if (v.STUDIPFORM_REDIRECTURL) { - window.location.href = v.STUDIPFORM_REDIRECTURL + success(output) { + if (output === 'STUDIPFORM_STORE_SUCCESS' && v.STUDIPFORM_REDIRECTURL) { + //The form has been stored successfully: + window.location.href = v.STUDIPFORM_REDIRECTURL; + } else if (output !== 'STUDIPFORM_STORE_SUCCESS') { + Report.error($gettext('Es ist ein Fehler aufgetreten'), output); } } }); diff --git a/resources/assets/javascripts/bootstrap/global_search.js b/resources/assets/javascripts/bootstrap/global_search.js index 4d0738e..0e179b7 100644 --- a/resources/assets/javascripts/bootstrap/global_search.js +++ b/resources/assets/javascripts/bootstrap/global_search.js @@ -27,15 +27,47 @@ STUDIP.domReady(() => { // Enlarge search input on focus and show hints. $('#globalsearch-input').on('focus', function() { STUDIP.GlobalSearch.toggleSearchBar(true, false); - }); - - // Start search on Enter - $('#globalsearch-input').on('keypress', function(e) { - if (e.which === 13) { + }).on('keypress', (e) => { + // Start search on Enter + if (e.key === 'Enter') { STUDIP.GlobalSearch.doSearch(); return false; } }); + $('#globalsearch-searchbar').on('keydown', function(e) { + if (!['ArrowDown', 'ArrowUp'].includes(e.key)) { + return; + } + + e.preventDefault(); + + // Get all possible items + const items = $('#globalsearch-list [role=listitem]:visible'); + + // Find focussed element + const focussed = items.filter(':focus'); + + // Get index of focussed element in all items + let index = focussed.length > 0 ? items.index(focussed[0]) : null; + + // Move focussed element up or down in items + if (e.key === 'ArrowDown') { + index = (index ?? -1) + 1; + } else { + index = (index ?? items.length) - 1; + } + + // Clamp index to sane boundaries + if (index < 0) { + index = 0; + } else if (index > items.length - 1) { + index = items.length - 1; + } + + // Focus new element by index + items.get(index).focus(); + }); + // Close search on click on page. $('#navigation-level-1, #current-page-structure, #main-footer').on('click', function() { diff --git a/resources/assets/javascripts/bootstrap/resources.js b/resources/assets/javascripts/bootstrap/resources.js index 8c89b7f..7eb6a68 100644 --- a/resources/assets/javascripts/bootstrap/resources.js +++ b/resources/assets/javascripts/bootstrap/resources.js @@ -416,7 +416,7 @@ STUDIP.ready(function () { $("#BookingEndDateInput").prop('defaultValue', $(this).val()); $("#BookingEndDateInput").val($(this).val()).trigger('change'); } - updateRepeatEndSemesterByTimestamp(Math.floor(d / 1000)); + updateRepeatEndSemesterByTimestamp(d); } else if ($(this).attr('id') == 'BookingEndDateInput') { $("#end_date-weekdays span").addClass('invisible'); $("#end_date-weekdays #" + day_numer).removeClass('invisible'); @@ -545,38 +545,41 @@ STUDIP.ready(function () { } ); - function updateRepeatEndSemesterByTimestamp(timestamp, api_url = 'api.php/semesters') { - var semester = null; - jQuery.ajax( - STUDIP.URLHelper.getURL(api_url), - { - method: 'get', - dataType: 'json', - success: function (data) { - if (data) { - Object.values(data.collection).forEach(item => { - if (timestamp >= item.begin && timestamp < item.end) { - semester = item; - } - }); - if (semester) { - $("#semester_course_name").text(semester.title); - $(".semester-time-option").prop('disabled', false); - } else { - if (data.pagination && data.pagination.links.next != api_url) { - semester = updateRepeatEndSemesterByTimestamp(timestamp, data.pagination.links.next); - } else { - $("#semester_course_name").text('außerhalb definierter Zeiten'); - $(".semester-time-option").prop('checked', false); - $(".semester-time-option").prop('disabled', true); - $(".manual-time-option").prop('checked', true); - $(".manual-time-option").trigger('change'); - } - } - } - } + function updateRepeatEndSemesterByTimestamp(timestamp) { + (new Promise((resolve, reject) => { + const cache = STUDIP.Cache.getInstance('jsonapi'); + if (cache.has('semesters')) { + resolve(cache.get('semesters')); + } else { + STUDIP.jsonapi.GET('semesters', { data: { page: { limit: 100000 }}}) + .done(({data}) => { + cache.set('semesters', data); + resolve(data) + }) + .fail(() => { + reject(new Error('Could not load semesters')); + }); + } + })).then(semesters => { + const semester = semesters.find(({attributes}) => { + return new Date(attributes.start) <= timestamp + && timestamp <= new Date(attributes.end); + }); + + if (semester) { + $('#semester_course_name').text(semester.attributes.title); + $('.semester-time-option').prop('disabled', false); + } else { + $('#semester_course_name').text('außerhalb definierter Zeiten'); + $('.semester-time-option').prop({ + checked: false, + disabled: true + }); + $('.manual-time-option') + .prop('checked', true) + .trigger('change'); } - ); + }); } function updateViewURL(defaultView) { diff --git a/resources/assets/javascripts/bootstrap/responsive-navigation.js b/resources/assets/javascripts/bootstrap/responsive-navigation.js index aa81107..ad39d2b 100644 --- a/resources/assets/javascripts/bootstrap/responsive-navigation.js +++ b/resources/assets/javascripts/bootstrap/responsive-navigation.js @@ -1,6 +1,6 @@ import ResponsiveNavigation from '../../../vue/components/responsive/ResponsiveNavigation.vue'; -STUDIP.ready(() => { +STUDIP.domReady(() => { STUDIP.Vue.load().then(({ createApp }) => { createApp({ el: '#responsive-menu', diff --git a/resources/assets/javascripts/bootstrap/search.js b/resources/assets/javascripts/bootstrap/search.js index 6a0559b..7d4fb76 100644 --- a/resources/assets/javascripts/bootstrap/search.js +++ b/resources/assets/javascripts/bootstrap/search.js @@ -2,8 +2,6 @@ STUDIP.domReady(() => { var cache = STUDIP.Search.getCache(); // initially hide all filters except for the semester filter $('#reset-search').hide(); - STUDIP.Search.hideAllFilters(); - $('div#semester_filter').show(); STUDIP.Search.setActiveCategory('show_all_categories'); STUDIP.Search.showActiveFilters(STUDIP.Search.getFilter()); diff --git a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js index c106de3..aea1823 100644 --- a/resources/assets/javascripts/bootstrap/studip_helper_attributes.js +++ b/resources/assets/javascripts/bootstrap/studip_helper_attributes.js @@ -276,6 +276,15 @@ $(document).on('click keydown', '[data-toggles]', function (event) { $(target).toggle(); } + const controls = $(event.currentTarget).attr('aria-controls'); + if (controls) { + // Find elements which control the expanded status of the same element. + const elements = $('[aria-controls="' + controls + '"]'); + const expanded = $(event.currentTarget).attr('aria-expanded') === 'true'; + // Set the aria-expanded status accordingly. + elements.attr('aria-expanded', !expanded); + } + event.preventDefault(); } }); diff --git a/resources/assets/javascripts/bootstrap/system-notifications.js b/resources/assets/javascripts/bootstrap/system-notifications.js new file mode 100644 index 0000000..7a85fcd --- /dev/null +++ b/resources/assets/javascripts/bootstrap/system-notifications.js @@ -0,0 +1,11 @@ +import SystemNotificationManager from '../../../vue/components/SystemNotificationManager.vue'; + +STUDIP.domReady(() => { + document.getElementById('system-notifications')?.classList.add('vueified'); + STUDIP.Vue.load().then(({ createApp }) => { + createApp({ + el: '#system-notifications', + components: { SystemNotificationManager } + }); + }); +}); diff --git a/resources/assets/javascripts/bootstrap/tooltip.js b/resources/assets/javascripts/bootstrap/tooltip.js deleted file mode 100644 index c84042b..0000000 --- a/resources/assets/javascripts/bootstrap/tooltip.js +++ /dev/null @@ -1,67 +0,0 @@ -// Attach global hover handler for tooltips. -// Applies to all elements having a "data-tooltip" attribute. -// Tooltip may be provided in the data-attribute itself or by -// defining a title attribute. The latter is prefered due to -// the obvious accessibility issues. - -var timeout = null; - -STUDIP.Tooltip.threshold = 6; - -$(document).on('mouseenter mouseleave focusin focusout', '[data-tooltip],.tooltip:has(.tooltip-content)', function(event) { - let data = $(this).data(); - - const visible = event.type === 'mouseenter' || event.type === 'focusin'; - const offset = $(this).offset(); - const x = offset.left + $(this).outerWidth(true) / 2; - const y = offset.top; - const delay = data.tooltipDelay ?? 300; - - let content; - let tooltip; - - if (!data.tooltipObject) { - // If tooltip has not yet been created (first hover), obtain it's - // contents and create the actual tooltip object. - if (!data.tooltip || !$.isPlainObject(data.tooltip)) { - content = $('<div/>').text(data.tooltip || $(this).attr('title')).html(); - } else if (data.tooltip.html !== undefined) { - content = data.tooltip.html; - } else if (data.tooltip.text !== undefined) { - content = data.tooltip.text; - } else { - throw "Invalid content for tooltip via data"; - } - if (!content) { - content = $(this).find('.tooltip-content').remove().html(); - } - $(this).attr('title', null); - $(this).attr('data-tooltip', content); - - tooltip = new STUDIP.Tooltip(x, y, content); - - data.tooltipObject = tooltip; - $(this).attr('aria-describedby', tooltip.id); - - $(this).on('remove', function() { - tooltip.remove(); - }); - } else if (visible) { - // If tooltip has already been created, update it's position. - // This is neccessary if the surrounding content is scrollable AND has - // been scrolled. Otherwise the tooltip would appear at it's previous - // and now wrong location. - data.tooltipObject.position(x, y); - } - - if (visible) { - $('.studip-tooltip').not(data.tooltipObject).hide(); - data.tooltipObject.show(); - } else { - timeout = setTimeout(() => data.tooltipObject.hide(), delay); - } -}).on('mouseenter focusin', '.studip-tooltip', () => { - clearTimeout(timeout); -}).on('mouseleave focusout', '.studip-tooltip', function() { - $(this).hide(); -}); diff --git a/resources/assets/javascripts/bootstrap/treeview.js b/resources/assets/javascripts/bootstrap/treeview.js index 998a70e..d132775 100644 --- a/resources/assets/javascripts/bootstrap/treeview.js +++ b/resources/assets/javascripts/bootstrap/treeview.js @@ -1,7 +1,8 @@ import StudipTree from '../../../vue/components/tree/StudipTree.vue' STUDIP.ready(() => { - document.querySelectorAll('[data-studip-tree]').forEach(element => { + document.querySelectorAll('[data-studip-tree]:not(.vueified)').forEach(element => { + element.classList.add('vueified'); STUDIP.Vue.load().then(({ createApp }) => { createApp({ el: element, diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js index b8c938d..c6816a2 100644 --- a/resources/assets/javascripts/bootstrap/vue.js +++ b/resources/assets/javascripts/bootstrap/vue.js @@ -28,27 +28,17 @@ STUDIP.ready(() => { }); STUDIP.Vue.load().then(async ({createApp, store}) => { - let vm; if (config.store) { const storeConfig = await import(`../../../vue/store/${config.store}.js`); - console.log('store', storeConfig.default); store.registerModule(config.id, storeConfig.default, {root: true}); Object.keys(data).forEach(command => { store.commit(`${config.id}/${command}`, data[command]); }); - vm = createApp({components}); - } else { - vm = createApp({data, components}); } - // import myCoursesStore from '../stores/MyCoursesStore.js'; - // - // myCoursesStore.namespaced = true; - // - // store.registerModule('my-courses', myCoursesStore); - vm.$mount(this); + createApp({components, data}).$mount(this); }); $(this).attr('data-vue-app-created', ''); diff --git a/resources/assets/javascripts/bootstrap/wysiwyg.js b/resources/assets/javascripts/bootstrap/wysiwyg.js index fb158bb..9e18cf8 100644 --- a/resources/assets/javascripts/bootstrap/wysiwyg.js +++ b/resources/assets/javascripts/bootstrap/wysiwyg.js @@ -6,8 +6,11 @@ STUDIP.domReady(() => { $(document).on('focus blur', '.studip-dialog .ck-editor__editable_inline', function(event) { let height = this.clientHeight; let editor = this.ckeditorInstance; - editor.editing.view.change(writer => { - writer.setStyle('height', height + 'px', editor.editing.view.document.getRoot()); + // this is needed on Chrome, see https://gitlab.studip.de/studip/studip/-/issues/3510 + setTimeout(() => { + editor.editing.view.change(writer => { + writer.setStyle('height', height + 'px', editor.editing.view.document.getRoot()); + }); }); }); diff --git a/resources/assets/javascripts/chunk-loader.js b/resources/assets/javascripts/chunk-loader.js index dcd95cc..f372286 100644 --- a/resources/assets/javascripts/chunk-loader.js +++ b/resources/assets/javascripts/chunk-loader.js @@ -8,95 +8,100 @@ export const loadScript = function (script_name) { }); }; -export const loadChunk = (function () { - let mathjax_promise = null; +let mathjax_promise = null; - return function (chunk) { - let promise = null; - switch (chunk) { +/** This function dynamically loads JS features organized in chunks. + * + * @param {string} chunk The name of the chunk to load. + * @param {{ silent: boolean }} options Options for loading the chunk. + * Pass `{ silent: true }` to supress + * error messages. + * @return {Promise} + */ +export const loadChunk = function (chunk, { silent = false } = {}) { + let promise = null; + switch (chunk) { + case 'code-highlight': + promise = import( + /* webpackChunkName: "code-highlight" */ + './chunks/code-highlight' + ).then(({ default: hljs }) => { + return hljs; + }); + break; - case 'code-highlight': - promise = import( - /* webpackChunkName: "code-highlight" */ - './chunks/code-highlight' - ).then(({default: hljs}) => { - return hljs; - }); - break; + case 'courseware': + promise = Promise.all([ + STUDIP.loadChunk('vue'), + import( + /* webpackChunkName: "courseware" */ + './chunks/courseware' + ), + ]).then(([Vue]) => Vue); + break; - case 'courseware': - promise = Promise.all([ - STUDIP.loadChunk('vue'), - import( - /* webpackChunkName: "courseware" */ - './chunks/courseware' - ), - ]).then(([Vue]) => Vue); - break; + case 'chartist': + promise = import( + /* webpackChunkName: "chartist" */ + './chunks/chartist' + ).then(({ default: Chartist }) => Chartist); + break; - case 'chartist': - promise = import( - /* webpackChunkName: "chartist" */ - './chunks/chartist' - ).then(({ default: Chartist }) => Chartist); - break; + case 'fullcalendar': + promise = import( + /* webpackChunkName: "fullcalendar" */ + './chunks/fullcalendar' + ); + break; - case 'fullcalendar': - promise = import( - /* webpackChunkName: "fullcalendar" */ - './chunks/fullcalendar' - ); - break; + case 'tablesorter': + promise = import( + /* webpackChunkName: "tablesorter" */ + './chunks/tablesorter' + ); + break; - case 'tablesorter': - promise = import( - /* webpackChunkName: "tablesorter" */ - './chunks/tablesorter' - ); - break; - - case 'mathjax': - if (mathjax_promise === null) { - mathjax_promise = STUDIP.loadScript( - 'javascripts/mathjax/MathJax.js?config=TeX-AMS_HTML,default' - ).then(() => { + case 'mathjax': + if (mathjax_promise === null) { + mathjax_promise = STUDIP.loadScript('javascripts/mathjax/MathJax.js?config=TeX-AMS_HTML,default') + .then(() => { (function (origPrint) { window.print = function () { - window.MathJax.Hub.Queue( - ['Delay', window.MathJax.Callback, 700], - origPrint - ); + window.MathJax.Hub.Queue(['Delay', window.MathJax.Callback, 700], origPrint); }; })(window.print); return window.MathJax; - }).catch(() => { - console.log('Could not load mathjax') + }) + .catch(() => { + throw new Error('Could not load mathjax'); }); - } - promise = mathjax_promise; - break; + } + promise = mathjax_promise; + break; - case 'vue': - promise = import( - /* webpackChunkName: "vue.js" */ - './chunks/vue' - ); - break; + case 'vue': + promise = import( + /* webpackChunkName: "vue.js" */ + './chunks/vue' + ); + break; - case 'wysiwyg': - promise = import( - /* webpackChunkName: "wysiwyg.js" */ - './chunks/wysiwyg' - ); - break; + case 'wysiwyg': + promise = import( + /* webpackChunkName: "wysiwyg.js" */ + './chunks/wysiwyg' + ); + break; - default: - promise = Promise.reject(new Error(`Unknown chunk: ${chunk}`)); - } + default: + promise = Promise.reject(new Error(`Unknown chunk: ${chunk}`)); + } - return promise.catch((error) => { + return promise.catch((error) => { + if (!silent) { console.error(`Could not load chunk ${chunk}`, error); - }); - }; -}()); + } + throw error; + }); +}; diff --git a/resources/assets/javascripts/chunks/vue.js b/resources/assets/javascripts/chunks/vue.js index b98cc27..8d506a1 100644 --- a/resources/assets/javascripts/chunks/vue.js +++ b/resources/assets/javascripts/chunks/vue.js @@ -39,6 +39,9 @@ Vue.mixin({ globalOn(...args) { eventBus.on(...args); }, + globalOff(...args) { + eventBus.off(...args); + }, getStudipConfig: store.getters['studip/getConfig'] }, }); diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js index 73b1aaa..5f88c30 100644 --- a/resources/assets/javascripts/entry-base.js +++ b/resources/assets/javascripts/entry-base.js @@ -16,6 +16,8 @@ import "./init.js" import "./bootstrap/responsive.js" import "./bootstrap/vue.js" +import "./bootstrap/system-notifications.js" + import "./bootstrap/my-courses.js"; import "./studip-ui.js" @@ -55,7 +57,6 @@ import "./bootstrap/article.js" import "./bootstrap/copyable_links.js" import "./bootstrap/selection.js" import "./bootstrap/data_secure.js" -import "./bootstrap/tooltip.js" import "./bootstrap/lightbox.js" import "./bootstrap/application.js" import "./bootstrap/global_search.js" diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js index 2d592be..1d7d5ac 100644 --- a/resources/assets/javascripts/init.js +++ b/resources/assets/javascripts/init.js @@ -38,7 +38,7 @@ import HeaderMagic from './lib/header_magic.js'; import i18n from './lib/i18n.js'; import Instschedule from './lib/instschedule.js'; import InlineEditing from './lib/inline-editing.js'; -import JSONAPI, { jsonapi } from './lib/jsonapi.js'; +import JSONAPI, { jsonapi } from './lib/jsonapi.ts'; import JSUpdater from './lib/jsupdater.js'; import Lightbox from './lib/lightbox.js'; import Markup from './lib/markup.js'; @@ -64,7 +64,6 @@ import register from './lib/register.js'; import Report from './lib/report.js'; import Resources from './lib/resources.js'; import Responsive from './lib/responsive.js'; -import RESTAPI, { api } from './lib/restapi.js'; import Schedule from './lib/schedule.js'; import Screenreader from './lib/screenreader.js'; import Scroll from './lib/scroll.js'; @@ -76,7 +75,6 @@ import Statusgroups from './lib/statusgroups.js'; import study_area_selection from './lib/study_area_selection.js'; import Table from './lib/table.js'; import TableOfContents from './lib/table-of-contents.js'; -import Tooltip from './lib/tooltip.js'; import Tour from './lib/tour.js'; import * as Gettext from './lib/gettext'; import UserFilter from './lib/user_filter.js'; @@ -93,7 +91,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, { admin_sem_class, AdminCourses, Admission, - api, Arbeitsgruppen, Archive, Avatar, @@ -152,7 +149,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, { register, Report, Responsive, - RESTAPI, Schedule, Scroll, Screenreader, @@ -164,7 +160,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, { study_area_selection, Table, TableOfContents, - Tooltip, Tour, URLHelper, UserFilter, diff --git a/resources/assets/javascripts/lib/RestrictedDatesHelper.ts b/resources/assets/javascripts/lib/RestrictedDatesHelper.ts new file mode 100644 index 0000000..bcc0af2 --- /dev/null +++ b/resources/assets/javascripts/lib/RestrictedDatesHelper.ts @@ -0,0 +1,89 @@ +import { jsonapi } from "./jsonapi"; + +type RestrictedDate = { + year: Number, + month: Number, + day: Number, + + reason: string | null, + lock: boolean +} + +class RestrictedDatesHelper +{ + static #loadedYears : Number[] = []; + static #restrictedDates: RestrictedDate[] = []; + + static isDateRestricted(date: Date, returnBoolean: Boolean = false): RestrictedDate | Boolean { + const restrictedDate : RestrictedDate | undefined = this.#restrictedDates.find(item => { + return item.year === date.getFullYear() + && item.month === date.getMonth() + 1 + && item.day === date.getDate(); + }); + + if (returnBoolean) { + return !!restrictedDate; + } + + return restrictedDate ?? this.#convertDate(date, null, false); + } + + static async loadRestrictedDatesByYear(year: Number): Promise<void> { + if (this.#loadedYears.includes(year)) { + return Promise.reject(); + } + + this.#loadedYears.push(year); + + jsonapi.withPromises().request('holidays', {data: { + 'filter[year]': year + }}).then((response: [] | Object) => { + // Since PHP will return an empty object as an array, + // we need to check + if (Array.isArray(response)) { + return; + } + + for (const [date, data] of Object.entries(response)) { + this.#addRestrictedDate( + new Date(date), + data.holiday, + data.mandatory + ); + } + }); + } + + static #addRestrictedDate(date: Date, reason: string, lock: boolean = true): void { + const restricted = this.#convertDate(date, reason, lock); + + this.#restrictedDates = this.#restrictedDates.filter(item => { + return item.year !== restricted.year + || item.month !== restricted.month + || item.day !== restricted.day; + }); + + this.#restrictedDates.push(restricted); + } + + static removeRestrictedDate(date: Date): void { + this.#restrictedDates = this.#restrictedDates.filter(item => { + return item.year !== date.getFullYear() + || item.month !== date.getMonth() + 1 + || item.day !== date.getDate(); + }); + } + + static #convertDate(date: Date, reason: string | null, lock: boolean): RestrictedDate { + return { + year: date.getFullYear(), + month: date.getMonth() + 1, + day: date.getDate(), + + reason, + lock + }; + } +} + +export default RestrictedDatesHelper; diff --git a/resources/assets/javascripts/lib/abstract-api.js b/resources/assets/javascripts/lib/abstract-api.js index eafca85..95ae015 100644 --- a/resources/assets/javascripts/lib/abstract-api.js +++ b/resources/assets/javascripts/lib/abstract-api.js @@ -1,5 +1,20 @@ import Overlay from './overlay.js'; +class APIError extends Error +{ + static createWithJqXhr(message, jqXhr) { + const error = new APIError(message); + error.setJqXhr(jqXhr); + return error; + } + + jqXhr = null; + + setJqXhr(jqXhr) { + this.jqXhr = jqXhr; + } +} + class AbstractAPI { static get supportedMethods() { @@ -52,6 +67,8 @@ class AbstractAPI var deferred; + const request = this.#createRequest(url, options); + if (options.async && this.request_count > 0) { // Request should be sent asynchronous after every other request // is finished. The configuration for this particular request is @@ -73,10 +90,10 @@ class AbstractAPI this.total_requests += 1; // Actual request - deferred = $.ajax(STUDIP.URLHelper.getURL(`${this.base_url}/${url}`, {}, true), { + deferred = $.ajax(request.url, { contentType: options.contentType || 'application/x-www-form-urlencoded; charset=UTF-8', method: options.method.toUpperCase(), - data: this.encodeData(options.data, options.method.toUpperCase()), + data: this.encodeData(request.data, options.method.toUpperCase()), headers: options.headers }).always(() => { // Decrease request counter, remove overlay if neccessary @@ -93,6 +110,54 @@ class AbstractAPI } }).promise(); } + + #createRequest(url, options) { + const hasBody = ['post', 'put', 'patch'].includes(options.method.toLowerCase()); + const query = hasBody ? '' : `?${this.convertDataToRequestParameters(options.data)}`; + + return { + url: STUDIP.URLHelper.getURL(`${this.base_url}/${url}${query}`, {}, true), + data: hasBody ? options.data : {}, + }; + } + + convertDataToRequestParameters(data, prefix = '') { + return Object.entries(data).filter(([key, value]) => { + return value !== null; + }).map(([key, value]) => { + const name = prefix ? `${prefix}[${key}]` : `${key}`; + if (value.constructor?.name === 'Object') { + return this.convertDataToRequestParameters(value, name); + } else { + return `${name}=${value}`; + } + }).join('&'); + } + + withPromises() { + return new Proxy(this, { + get(target, prop, receiver) { + // This will allow http methods to be written as lowercase when called as methods + // (e.g. api.patch() instead of api.PATCH()) + if (target[prop] === undefined && AbstractAPI.supportedMethods.includes(prop.toUpperCase())) { + prop = prop.toUpperCase(); + } + + // Only handle calls to request methods + if (prop !== 'request') { + return Reflect.get(target, prop, receiver); + } + + // Return a wrapped promise that handles the deferred + return (url, options = {}) => new Promise((resolve, reject) => { + target[prop].apply(target, [url, options]).then( + (response) => resolve(response), + (jqXhr, textStatus, errorThrown) => reject(APIError.createWithJqXhr(errorThrown || textStatus, jqXhr)) + ); + }); + } + }) + } } // Create shortcut methods for easier access by method diff --git a/resources/assets/javascripts/lib/activityfeed.js b/resources/assets/javascripts/lib/activityfeed.js index 74c27f9..12f0bac 100644 --- a/resources/assets/javascripts/lib/activityfeed.js +++ b/resources/assets/javascripts/lib/activityfeed.js @@ -6,13 +6,13 @@ const ActivityFeed = { maxheight: null, filter: null, - init: function() { + init() { STUDIP.ActivityFeed.maxheight = parseInt($('#stream-container').css('max-height').replace(/[^-\d.]/g, '')); STUDIP.ActivityFeed.loadFeed(STUDIP.ActivityFeed.filter); - $('#stream-container').scroll(function () { - var scrollBottom = $('#stream-container').scrollTop() + $('#stream-container').height() + 250; + $('#stream-container').scroll(() => { + const scrollBottom = $('#stream-container').scrollTop() + $('#stream-container').height() + 250; if ($('#stream-container').prop('scrollHeight') < scrollBottom) { STUDIP.ActivityFeed.loadFeed(STUDIP.ActivityFeed.filter); @@ -23,7 +23,7 @@ const ActivityFeed = { $(document).on('click', '.provider_circle', function () { $(this).parent().parent().children('.activity-content').toggle(); }).on('click', '#toggle-all-activities,#toggle-user-activities', function () { - var toggled = $(this).is(':not(.toggled)'); + const toggled = $(this).is(':not(.toggled)'); $(this).toggleClass('toggled', toggled); STUDIP.ActivityFeed.setToggleStatus(); @@ -32,11 +32,11 @@ const ActivityFeed = { }); }, - getTemplate: _.memoize(function(name) { - return _.template($("script." + name).html()); + getTemplate: _.memoize(name => { + return _.template($(`script.${name}`).html()); }), - loadFeed: function(filtertype) { + loadFeed(filtertype) { if (STUDIP.ActivityFeed.user_id === null) { console.log('Could not retrieve activities, no valid user id found!'); return false; @@ -48,17 +48,18 @@ const ActivityFeed = { STUDIP.ActivityFeed.polling = true; - STUDIP.api.GET(['user', STUDIP.ActivityFeed.user_id, 'activitystream'], { - data: { - filtertype: JSON.stringify(filtertype), - scrollfrom: STUDIP.ActivityFeed.scrolledfrom - } - }).done(function (activities) { - var stream = STUDIP.ActivityFeed.getTemplate('activity_stream'); - var activity = STUDIP.ActivityFeed.getTemplate('activity'); - var activity_urls = STUDIP.ActivityFeed.getTemplate('activity-urls'); - var num_entries = Object.keys(activities).length; - var lastelem = $(activities).last(); + const url = STUDIP.URLHelper.getURL('dispatch.php/activityfeed/load', { + filtertype: JSON.stringify(filtertype), + scrollfrom: STUDIP.ActivityFeed.scrolledfrom, + }); + fetch(url).then( + response => response.json(), + ).then(activities => { + const stream = STUDIP.ActivityFeed.getTemplate('activity_stream'); + const activity = STUDIP.ActivityFeed.getTemplate('activity'); + const activity_urls = STUDIP.ActivityFeed.getTemplate('activity-urls'); + const num_entries = Object.keys(activities).length; + const lastelem = $(activities).last(); if (lastelem[0]) { STUDIP.ActivityFeed.scrolledfrom = lastelem[0].mkdate; @@ -79,15 +80,15 @@ const ActivityFeed = { if ($('#stream-container').height() < STUDIP.ActivityFeed.maxheight) { STUDIP.ActivityFeed.loadFeed(''); } - }).fail(function () { - var template = STUDIP.ActivityFeed.getTemplate('activity-load-error'); + }).catch(() => { + const template = STUDIP.ActivityFeed.getTemplate('activity-load-error'); STUDIP.ActivityFeed.writeToStream(template()); - }).always(function () { + }).finally(() => { STUDIP.ActivityFeed.polling = false; }); }, - writeToStream: function (html) { + writeToStream(html) { if (STUDIP.ActivityFeed.initial) { // replace data in DOM $('#stream-container').html(''); @@ -98,9 +99,9 @@ const ActivityFeed = { $('#stream-container').append(html); }, - setToggleStatus: function() { - var show_details = $('#toggle-all-activities').is('.toggled'), - show_own = $('#toggle-user-activities').is('.toggled'); + setToggleStatus() { + const show_details = $('#toggle-all-activities').is('.toggled'); + const show_own = $('#toggle-user-activities').is('.toggled'); // update toggle status fir activity contents $('.activity-content').toggle(show_details); @@ -109,7 +110,7 @@ const ActivityFeed = { $('.activity:has(.provider_circle.right)').toggle(show_own); }, - updateFilter: function(filter) { + updateFilter(filter) { STUDIP.ActivityFeed.filter = filter; STUDIP.ActivityFeed.initial = true; STUDIP.ActivityFeed.scrolledfrom = Math.floor(Date.now() / 1000); diff --git a/resources/assets/javascripts/lib/blubber.js b/resources/assets/javascripts/lib/blubber.js index 3e93985..4e724b4 100644 --- a/resources/assets/javascripts/lib/blubber.js +++ b/resources/assets/javascripts/lib/blubber.js @@ -47,6 +47,7 @@ const Blubber = { subscribe: follow, }); }).then(() => { + elements.attr('aria-pressed', follow ? 'true' : 'false'); elements.toggleClass('unfollowed', !follow); }).finally(() => { elements.removeClass('loading'); diff --git a/resources/assets/javascripts/lib/clipboard.js b/resources/assets/javascripts/lib/clipboard.js index e5890ab..6fd1489 100644 --- a/resources/assets/javascripts/lib/clipboard.js +++ b/resources/assets/javascripts/lib/clipboard.js @@ -1,4 +1,14 @@ -import {$gettext} from './gettext'; +function extractAttribute(node, attribute) { + return node.querySelector(`input[name="${attribute}"]`)?.value.trim(); +} + +function extractAttributes(node, attributes) { + const result = {}; + for (const key of attributes) { + result[key] = extractAttribute(node, key); + } + return result; +} const Clipboard = { switchClipboard: function(event) { @@ -32,32 +42,30 @@ const Clipboard = { } }, - handleAddForm: function(event) { - if (!event) { - return false; - } - + handleAddForm(event) { + event.preventDefault(); + const attributes = extractAttributes(event.target, ['name', 'allowed_item_class']); //Check if a name is entered in the form: - let name_input = jQuery(event.target).find('input[type="text"][name="name"]'); + const name_input = event.target.querySelector('input[name="name"]'); if (!name_input) { //Something is wrong with the HTML: return false; } - let name = jQuery(name_input).val().trim(); - if (!name) { + if (!attributes.name) { //The name field is empty. Why send an empty field? return false; } - //Submit the form via AJAX: - STUDIP.api.POST( - 'clipboard/add', - { - data: jQuery(event.target).serialize() - } - ).done(STUDIP.Clipboard.add); + // Submit the form via AJAX: + STUDIP.jsonapi.POST('clipboards', {data: {data: {attributes}}}).done(({data}) => { + STUDIP.Clipboard.add({ + id: data.id, + name: data.attributes.name, + widget_id: extractAttribute(event.target, 'widget_id') + }); + }); }, add: function(data) { @@ -134,11 +142,9 @@ const Clipboard = { jQuery(widget_node).find('#clipboard-group-container').removeClass('invisible'); //Call the droppable jQuery method on the new clipboard area: - jQuery(clipboard_node).droppable( - { - drop: STUDIP.Clipboard.handleItemDrop - } - ); + jQuery(clipboard_node).droppable({ + drop: STUDIP.Clipboard.handleItemDrop + }); //Clear the text input in the "add clipboard" form: jQuery(widget_node).find( @@ -238,17 +244,19 @@ const Clipboard = { } //Add the item to the clipboard via AJAX: - STUDIP.api.POST( - 'clipboard/' + clipboard_id + '/item', - { + STUDIP.jsonapi.POST(`clipboards/${clipboard_id}/items`, { + data: { data: { - 'range_id': range_id, - 'range_type': range_type, - 'widget_id': widget_id + attributes: { range_id, range_type } } } - ).done(function(data) { - STUDIP.Clipboard.addDroppedItem(data); + }).done(({data}) => { + STUDIP.Clipboard.addDroppedItem({ + id: data.id, + name: data.attributes.name, + range_id: data.attributes.range_id, + widget_id + }); }); }, @@ -263,6 +271,7 @@ const Clipboard = { let widget = jQuery('#ClipboardWidget_' + response_data['widget_id']); let clipboard_id = jQuery(widget).find(".clipboard-selector").val(); + if (!widget) { //The widget with the speicified widget-ID //is not present on the current page. @@ -302,7 +311,6 @@ const Clipboard = { jQuery(new_item_node).removeClass('invisible'); let name_column = jQuery(new_item_node).find('td.item-name'); - console.log(name_column); jQuery('<span/>').text(response_data['name']).appendTo(name_column) let id_field = jQuery(new_item_node).find("input[name='selected_clipboard_items[]']"); jQuery(id_field).val(checkbox_id); @@ -325,25 +333,16 @@ const Clipboard = { ); }, - rename: function(widget_id) { - if (!widget_id) { - //Required data are missing! - return; - } + rename(widget_id) { + const widget = jQuery('#ClipboardWidget_' + widget_id); + const clipboard_id = widget.find('.clipboard-selector').val(); + const name = widget.find('input.clipboard-name').val(); - let widget = jQuery('#ClipboardWidget_' + widget_id); - let clipboard_id = jQuery(widget).find(".clipboard-selector").val(); - let namer = jQuery(widget).find("input.clipboard-name"); - - STUDIP.api.PUT( - 'clipboard/' + clipboard_id, - { - data: { - name: namer.val() - } - } - ).done(function(data) { - STUDIP.Clipboard.update(data, widget_id) + STUDIP.jsonapi.PATCH(`clipboards/${clipboard_id}`, {data: {data: {attributes: {name}}}}).done(({data}) => { + STUDIP.Clipboard.update({ + id: data.id, + name: data.attributes.name, + }, widget_id) }); }, @@ -358,7 +357,7 @@ const Clipboard = { STUDIP.Clipboard.toggleEditButtons(widget_id); }, - remove: function(clipboard_id, widget_id) { + remove(clipboard_id, widget_id) { if (!clipboard_id || !widget_id) { //Required data are missing! return; @@ -427,10 +426,6 @@ const Clipboard = { }, handleRemoveClick: function(delete_icon) { - if (!delete_icon) { - return; - } - //Get the data of the clipboard: let clipboard_select = jQuery(delete_icon).siblings('.clipboard-selector')[0]; if (!clipboard_select) { @@ -444,52 +439,42 @@ const Clipboard = { //Another case where something is wrong with the HTML. return; } - let widget_id = jQuery(widget).data('widget_id'); - STUDIP.api.DELETE( - 'clipboard/' + clipboard_id, - { - data: { - widget_id: widget_id - } - } - ).done(function() { + const widget_id = jQuery(widget).data('widget_id'); + + STUDIP.jsonapi.DELETE(`clipboards/${clipboard_id}`).done(() => { STUDIP.Clipboard.remove(clipboard_id, widget_id); }); }, - removeItem: function(delete_icon) { - if (!delete_icon) { - return; - } - - //Get the item-ID: - let item_html = jQuery(delete_icon).parents('tr'); - let range_id = jQuery(item_html).data('range_id'); - let clipboard_element = jQuery(item_html).parents('table'); - let clipboard_id = jQuery(clipboard_element).data('id'); + removeItem(delete_icon) { + // Get the item-ID: + const item_element = jQuery(delete_icon).parents('tr'); + const range_id = jQuery(item_element).data('range_id'); + const clipboard_element = jQuery(item_element).parents('table'); + const clipboard_id = jQuery(clipboard_element).data('id'); if (!range_id || !clipboard_id) { //We cannot proceed without the item-ID and the clipboard-ID! return; } - STUDIP.api.DELETE( - 'clipboard/' + clipboard_id + '/item/' + range_id - ).done(function() { + STUDIP.jsonapi.DELETE(`clipboards/${clipboard_id}/items`, { + data: { + filter: { range_id } + } + }).done(() => { //Check if the item has siblings: - let siblings = jQuery(item_html).siblings(); + let siblings = item_element.siblings(); if (siblings.length < 3) { //Only the "no items" element and the template //are siblings of the item. //We must display the "no items" element: - jQuery(item_html).siblings( - '.empty-clipboard-message' - ).removeClass('invisible'); + item_element.siblings('.empty-clipboard-message').removeClass('invisible'); jQuery("#clipboard-group-container").find('.widget-links').addClass('invisible'); } //Finally remove the item: - jQuery(item_html).remove(); + item_element.remove(); }); }, diff --git a/resources/assets/javascripts/lib/dialog.js b/resources/assets/javascripts/lib/dialog.js index b5cab54..8f6e50a 100644 --- a/resources/assets/javascripts/lib/dialog.js +++ b/resources/assets/javascripts/lib/dialog.js @@ -386,7 +386,12 @@ Dialog.show = function(content, options = {}) { .before(element); } - $(this).parent().find('.ui-dialog-title').attr('title', options.title); + $(this).parent().find('.ui-dialog-title').attr({ + title: options.title, + role: 'heading', + 'aria-level': 2 + }); + $(this).parents('.studip-dialog').attr('aria-modal', 'true'); instance.open = true; // Execute scripts diff --git a/resources/assets/javascripts/lib/extract_callback.js b/resources/assets/javascripts/lib/extract_callback.js index bf7ac79..a630275 100644 --- a/resources/assets/javascripts/lib/extract_callback.js +++ b/resources/assets/javascripts/lib/extract_callback.js @@ -56,8 +56,9 @@ export default function extractCallback(cmd, payload, root = window) { } } - if (callback[chunk] === undefined) { - throw 'Error: Undefined callback ' + cmd; + if (callback === null || callback[chunk] === undefined) { + console.log('Error: Undefined callback ' + cmd); + return; } if (typeof callback[chunk] === 'function' && parameters !== null) { diff --git a/resources/assets/javascripts/lib/fullcalendar.js b/resources/assets/javascripts/lib/fullcalendar.js index 49274f4..5b7d032 100644 --- a/resources/assets/javascripts/lib/fullcalendar.js +++ b/resources/assets/javascripts/lib/fullcalendar.js @@ -622,8 +622,13 @@ class Fullcalendar $('.fc-slats tr:odd .fc-widget-content:not(.fc-axis)').remove(); } - STUDIP.api.GET(`semester/${timestamp}/week`).done((data) => { + if (document.getElementById('booking-plan-header-semname') === null) { + return; + } + $.getJSON( + STUDIP.URLHelper.getURL(`dispatch.php/resources/ajax/semester_week/${timestamp}`) + ).done((data) => { if (data) { $('#booking-plan-header-semname').text(data.semester_name); if (data.sem_week) { @@ -640,7 +645,7 @@ class Fullcalendar $('#booking-plan-header-semrow').hide(); $('#booking-plan-header-semweek-part').hide(); } - }) + }); }, resourceRender (renderInfo) { if ($(renderInfo.view.context.calendar.el).hasClass('room-group-booking-plan')) { @@ -731,12 +736,31 @@ class Fullcalendar //Get the timestamp: let timestamp = changedMoment.getTime() / 1000; - jQuery('a.resource-bookings-actions').each(function () { + jQuery('a.resource-bookings-actions, a.calendar-action').each(function () { const url = new URL(this.href); - url.searchParams.set('timestamp', timestamp) + url.searchParams.set('timestamp', timestamp.toString()) url.searchParams.set('defaultDate', changed_date) this.href = url.toString(); }); + jQuery('.sidebar-widget.calendar-action').each(function() { + //Each sidebar widget is different. The placement of the defaultDate URL parameter + //has to reflect that. + jQuery(this).find('button[formaction]').each(function() { + //Modify the formaction attribute: + let url = new URL(jQuery(this).attr('formaction')); + url.searchParams.set('defaultDate', changed_date); + jQuery(this).attr('formaction', url.toString()); + }); + jQuery(this).find('form[action]').each(function() { + //Add a hidden input with the defaultDate: + let hidden_input = jQuery(this).find('input[name="defaultDate"]')[0]; + if (!hidden_input) { + hidden_input = jQuery('<input type="hidden" name="defaultDate">'); + jQuery(this).append(hidden_input); + } + jQuery(hidden_input).val(changed_date); + }); + }); // Now change the URL of the window. const url = new URL(window.location.href); diff --git a/resources/assets/javascripts/lib/global_search.js b/resources/assets/javascripts/lib/global_search.js index dbd045b..394b7e3 100644 --- a/resources/assets/javascripts/lib/global_search.js +++ b/resources/assets/javascripts/lib/global_search.js @@ -9,6 +9,7 @@ const GlobalSearch = { */ toggleSearchBar: function(visible, cleanup) { $('#globalsearch-searchbar').toggleClass('is-visible', visible); + $('#globalsearch-input').attr('aria-expanded', visible ? 'true' : 'false'); $('#globalsearch-input').toggleClass('hidden-small-down', !visible); $('#globalsearch-icon').toggleClass('hidden-small-down', visible); $('#globalsearch-clear').toggleClass('hidden-small-down', !visible); @@ -70,7 +71,7 @@ const GlobalSearch = { // Iterate over each result category. $.each(json, function(name, value) { // Create an <article> for category. - var category = $(`<article id="globalsearch-${name}">`), + var category = $(`<article id="globalsearch-${name}" role="list">`), header = $('<header>').appendTo(category), counter = 0; @@ -96,7 +97,7 @@ const GlobalSearch = { // Process results and create corresponding entries. $.each(value.content, function(index, result) { // Create single result entry. - var single = $('<section>'), + var single = $(`<a href="${result.url}" role="listitem" ${dataDialog}>`), data = $('<div class="globalsearch-result-data">'), details = $('<div class="globalsearch-result-details">'); @@ -107,17 +108,17 @@ const GlobalSearch = { // Which result types should be opened via dialog? const openInDialog = ['GlobalSearchFiles', 'GlobalSearchMessages']; var dataDialog = (openInDialog.indexOf(name) >= 0 ? dataDialog = 'data-dialog' : dataDialog = ''); - var link = $(`<a href="${result.url}" ${dataDialog}>`).appendTo(single); + //var link = $(`<a href="${result.url}" ${dataDialog}>`).appendTo(single); // Optional image... if (result.img !== null) { - $(`<img src="${result.img}">`) + $(`<img src="${result.img}" alt="">`) .wrap('<div class="globalsearch-result-img">') .parent() // Element is now the wrapper - .appendTo(link); + .appendTo(single); } - link.append(data); + single.append(data); // Name/title $('<div class="globalsearch-result-title">') @@ -144,7 +145,7 @@ const GlobalSearch = { if (result.date !== null) { $('<div class="globalsearch-result-time">') .html(result.date) - .appendTo(link); + .appendTo(single); } // "Expand" attribute for further, result-related search @@ -178,6 +179,7 @@ const GlobalSearch = { GlobalSearch.lastSearch = null; $('#globalsearch-searchbar').removeClass('is-visible has-value'); + $('#globalsearch-input').attr('aria-expanded', 'false'); $('#globalsearch-input').val(''); $('#globalsearch-results').html(''); $('#globalsearch-input').focus(); diff --git a/resources/assets/javascripts/lib/header_magic.js b/resources/assets/javascripts/lib/header_magic.js index f465e7e..a581107 100644 --- a/resources/assets/javascripts/lib/header_magic.js +++ b/resources/assets/javascripts/lib/header_magic.js @@ -17,7 +17,7 @@ const scroll = function(scrolltop) { const HeaderMagic = { enable() { fold = $('#navigation-level-1').height(); - Scroll.addHandler('header', scroll); + Scroll.addHandler('header', scroll, true); }, disable() { Scroll.removeHandler('header'); diff --git a/resources/assets/javascripts/lib/jsonapi.js b/resources/assets/javascripts/lib/jsonapi.ts index f3217bc..80176cc 100644 --- a/resources/assets/javascripts/lib/jsonapi.js +++ b/resources/assets/javascripts/lib/jsonapi.ts @@ -3,11 +3,11 @@ import AbstractAPI from './abstract-api.js'; // Actual JSONAPI object class JSONAPI extends AbstractAPI { - constructor(version = 1) { + constructor(version: number = 1) { super(`jsonapi.php/v${version}`); } - encodeData (data, method) { + encodeData (data: any, method: string): any { data = super.encodeData(data); if (['DELETE', 'GET', 'HEAD'].includes(method)) { @@ -21,11 +21,11 @@ class JSONAPI extends AbstractAPI return JSON.stringify(data); } - request (url, options = {}) { + request (url: string, options: any = {}) { options.contentType = 'application/vnd.api+json'; return super.request(url, options); } } export default JSONAPI; -export const jsonapi = new JSONAPI(); +export const jsonapi: JSONAPI = new JSONAPI(); diff --git a/resources/assets/javascripts/lib/messages.js b/resources/assets/javascripts/lib/messages.js index 7ce5328..8e27f8f 100644 --- a/resources/assets/javascripts/lib/messages.js +++ b/resources/assets/javascripts/lib/messages.js @@ -252,6 +252,8 @@ const Messages = { if (jQuery('#' + name).is(':visible')) { jQuery('#' + name)[0].scrollIntoView(false); } + jQuery('#toggle-' + name) + .attr('aria-expanded', jQuery('#toggle-' + name).attr('aria-expanded') !== 'true'); } }; diff --git a/resources/assets/javascripts/lib/personal_notifications.js b/resources/assets/javascripts/lib/personal_notifications.js index 90f1053..392e8b0 100644 --- a/resources/assets/javascripts/lib/personal_notifications.js +++ b/resources/assets/javascripts/lib/personal_notifications.js @@ -116,6 +116,11 @@ const PersonalNotifications = { .click(STUDIP.PersonalNotifications.activate); } } + + // Special handling for personal notifications: + $('#notification-container').on('mouseover mouseout', function (event) { + $(this).attr('aria-expanded', $(this).attr('aria-expanded') === 'true' ? 'false' : 'true'); + }); }, activate () { Promise.resolve(Notification.requestPermission()).then(permission => { diff --git a/resources/assets/javascripts/lib/questionnaire.js b/resources/assets/javascripts/lib/questionnaire.js index 2bca8c6..9a89348 100644 --- a/resources/assets/javascripts/lib/questionnaire.js +++ b/resources/assets/javascripts/lib/questionnaire.js @@ -86,7 +86,7 @@ const Questionnaire = { } $.post(STUDIP.URLHelper.getURL('dispatch.php/questionnaire/store/' + (this.data.id || '')), { questionnaire: data, - questions_data: questions, + questions_data: JSON.stringify(questions), range_type: this.range_type, range_id: this.range_id }).done(() => { @@ -112,7 +112,7 @@ const Questionnaire = { id: id, questiontype: this.questions[i].questiontype, internal_name: this.questions[i].internal_name, - questiondata: Object.assign({}, this.questions[i].questiondata) + questiondata: JSON.parse(JSON.stringify(this.questions[i].questiondata)), }); this.activeTab = id; }, diff --git a/resources/assets/javascripts/lib/resources.js b/resources/assets/javascripts/lib/resources.js index 3287b42..6ff4156 100644 --- a/resources/assets/javascripts/lib/resources.js +++ b/resources/assets/javascripts/lib/resources.js @@ -50,7 +50,7 @@ class Resources jQuery(row_tds[user_td_index]).children('input').removeAttr('disabled'); if (username) { - jQuery(row_tds[user_td_index]).append(username); + jQuery('<span>').text(username).appendTo(row_tds[user_td_index]); } else { jQuery(row_tds[user_td_index]).append('ID ' + user_id); } @@ -60,8 +60,6 @@ class Resources } jQuery(user_id_input).val(user_id); - var perm_select = jQuery(row_tds[user_td_index + 1]).children()[0]; - if (temp_perms_row) { //Set the time input fields to useful values: @@ -134,22 +132,19 @@ class Resources jQuery(table_element).trigger('update'); }; - STUDIP.api.GET( - `user/${user_id}` - ).done(function (data) { - var username = data.name.family - + ', ' - + data.name.given; - if (data.name.prefix) { - username += ', ' + data.name.prefix; + STUDIP.jsonapi.GET(`users/${user_id}`).done(data => { + const attributes = data.data.attributes; + + let username = `${attributes['family-name']}, ${attributes['given-name']}`; + if (attributes['name-prefix']) { + username += `, ${attributes['name-prefix']}`; } - if (data.name.suffix) { - username += ' ' + data.name.suffix; + if (attributes['name-suffix']) { + username += ` ${attributes['name-suffix']}`; } - username += ' (' + data.name.username + ')' - + ' (' + data.perms + ')'; + username += ` (${attributes.username}) (${attributes.permission})`; insert_function(user_id, username); - }).fail(function () { + }).fail(() => { insert_function(user_id); }); } @@ -160,23 +155,13 @@ class Resources return; } - STUDIP.api.GET( - `course/${course_id}/members`, - { - data: { - //The limit '0' results in a division by zero. - //Hopefully, the limit is set to a value high enough: - limit: 1000000 - } - } - ).done(function (data) { - for (var attribute in data.collection) { - var user_id = data.collection[attribute].member.id; + STUDIP.jsonapi.GET(`courses/${course_id}/memberships`, {data: {page: {limit: 1000000}}}).done(data => { + data.data.forEach(membership => { STUDIP.Resources.addUserToPermissionList( - user_id, + membership.relationships.user.data.id, table_element ); - } + }); }); } diff --git a/resources/assets/javascripts/lib/restapi.js b/resources/assets/javascripts/lib/restapi.js deleted file mode 100644 index b6e31df..0000000 --- a/resources/assets/javascripts/lib/restapi.js +++ /dev/null @@ -1,12 +0,0 @@ -import AbstractAPI from './abstract-api.js'; - -// Actual RESTAPI object -class RESTAPI extends AbstractAPI -{ - constructor() { - super('api.php'); - } -} - -export default RESTAPI; -export const api = new RESTAPI(); diff --git a/resources/assets/javascripts/lib/scroll.js b/resources/assets/javascripts/lib/scroll.js index a4d24d5..f4ffc66 100644 --- a/resources/assets/javascripts/lib/scroll.js +++ b/resources/assets/javascripts/lib/scroll.js @@ -6,49 +6,54 @@ * Updates/calls to the callback are synchronized to screen refresh by using * the animation frame method (which will fallback to a timer based solution). */ -var handlers = {}; -var animId = false; +const handlers = {}; +let animId = false; -var lastTop = null; -var lastLeft = null; - -function scrollHandler() { - var scrollTop = $(document).scrollTop(); - var scrollLeft = $(document).scrollLeft(); - - if (scrollTop !== lastTop || scrollLeft !== lastLeft) { - $.each(handlers, function(index, handler) { - handler(scrollTop, scrollLeft); - }); - - lastTop = scrollTop; - lastLeft = scrollLeft; - } - - animId = false; - - engageScrollTrigger(); -} +let lastTop = null; +let lastLeft = null; function refresh() { - var hasHandlers = !$.isEmptyObject(handlers); + const hasHandlers = Object.keys(handlers).length > 0; if (!hasHandlers && animId !== false) { window.cancelAnimationFrame(animId); animId = false; } else if (hasHandlers && animId === false) { - animId = window.requestAnimationFrame(scrollHandler); + animId = window.requestAnimationFrame(() => Scroll.executeHandlers()); } } function engageScrollTrigger() { - $(window).off('scroll.studip-handler'); - $(window).one('scroll.studip-handler', refresh); + window.removeEventListener('scroll', refresh); + window.addEventListener('scroll', refresh, {once: true}); } const Scroll = { - addHandler(index, handler) { + executeHandlers(only_these = []) { + const scrollTop = document.scrollingElement.scrollTop; + const scrollLeft = document.scrollingElement.scrollLeft; + + if (scrollTop !== lastTop || scrollLeft !== lastLeft) { + for (const [index, handler] of Object.entries(handlers)) { + if (only_these.length === 0 || only_these.includes(index)) { + handler(scrollTop, scrollLeft); + } + } + + lastTop = scrollTop; + lastLeft = scrollLeft; + } + + animId = false; + + engageScrollTrigger(); + }, + addHandler(index, handler, immediate = false) { handlers[index] = handler; engageScrollTrigger(); + + if (immediate) { + Scroll.executeHandlers([index]); + } }, removeHandler(index) { delete handlers[index]; diff --git a/resources/assets/javascripts/lib/scroll_to_top.js b/resources/assets/javascripts/lib/scroll_to_top.js index 2a75402..9b0d3ee 100644 --- a/resources/assets/javascripts/lib/scroll_to_top.js +++ b/resources/assets/javascripts/lib/scroll_to_top.js @@ -4,9 +4,9 @@ let fold; let was_below_the_fold = false; const back_to_top = function(scrolltop) { - var is_below_the_fold = scrolltop > fold; + let is_below_the_fold = scrolltop > fold; if (is_below_the_fold !== was_below_the_fold) { - $('#scroll-to-top').toggleClass('hide', !is_below_the_fold); + document.getElementById('scroll-to-top').classList.toggle('hide', !is_below_the_fold); was_below_the_fold = is_below_the_fold; } }; @@ -23,15 +23,21 @@ const ScrollToTop = { }, disable() { Scroll.removeHandler('header'); - $('#scroll-to-top').addClass('hide'); + document.getElementById('scroll-to-top').classList.add('hide'); }, moveBack() { - $('#scroll-to-top').on('click', function(e) { - $('html, body').stop().animate({ - scrollTop: (0) - }, 1000, 'easeInOutExpo'); - e.preventDefault(); + document.getElementById('scroll-to-top').addEventListener('click', (evt) => { + evt.preventDefault(); + this.toTop(); }); + document.getElementById('scroll-to-top').addEventListener('keypress', (evt) => { + if (evt.code === 'Space') { + this.toTop(); + } + }); + }, + toTop() { + window.scroll({top: 0, left: 0, behavior: 'smooth'}); } }; diff --git a/resources/assets/javascripts/lib/search.js b/resources/assets/javascripts/lib/search.js index 5d39f43..f8108cd 100644 --- a/resources/assets/javascripts/lib/search.js +++ b/resources/assets/javascripts/lib/search.js @@ -200,7 +200,7 @@ const Search = { // Optional image... if (result.img !== null) { $('<div class="search-result-img hidden-tiny-down">') - .append(`<img src="${result.img}">`) + .append(`<img src="${result.img}" alt="">`) .appendTo(link); } @@ -339,7 +339,8 @@ const Search = { * Hide all select filters in the sidebar. */ hideAllFilters: function () { - $('div[id$="_filter"]').hide(); + $('#filter_widget').hide(); + $('#filter_widget *[id$="_filter"]').hide(); }, /** @@ -350,12 +351,11 @@ const Search = { showFilter: function (category) { var filters = $('#search-results').data('filters'); STUDIP.Search.hideAllFilters(); - if (filters && filters[category] !== undefined && category != 'show_all_categories') { + if (filters && filters[category] !== undefined && filters[category].length > 0) { + $('#filter_widget').show(); for (let i = 0; i < filters[category].length; i++) { $(`#${filters[category][i]}_filter`).show(); } - } else if (category === 'show_all_categories') { - $('#semester_filter').show(); } }, @@ -547,7 +547,7 @@ const Search = { if (item != 'category') { var value = filter[item]; if (value.trim()) { - var name = $(`#${item}_filter .sidebar-widget-header`).text().trim(); + var name = $(`#${item}_filter .label-text`).text().trim(); var value_text = $(`#${item}_select option:selected`).text().trim(); var filterItem = $('<button></button>').addClass('button remove-filter').text(name + ': ' + value_text).attr('data-filter-name', item); filterItem.on('click', function () { diff --git a/resources/assets/javascripts/lib/tooltip.js b/resources/assets/javascripts/lib/tooltip.js deleted file mode 100644 index 2cdac27..0000000 --- a/resources/assets/javascripts/lib/tooltip.js +++ /dev/null @@ -1,227 +0,0 @@ -import CSS from './css.js'; - -/** - * Tooltip library for Stud.IP - * - * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> - * @copyright Stud.IP Core Group 2014 - * @license GPL2 or any later version - * @since Stud.IP 3.1 - */ - -let count = 0; -let threshold = 0; - -class Tooltip { - static get count() { - return count; - } - - static set count(value) { - count = value; - } - - // Threshold used for "edge detection" (imagine a padding along the edges) - static get threshold() { - return threshold; - } - - static set threshold(value) { - threshold = value; - } - - /** - * Returns a new unique id of a tooltip. - * - * @return {string} Unique id - * @static - */ - static getId() { - const id = `studip-tooltip-${Tooltip.count}`; - Tooltip.count += 1; - return id; - } - - /** - * Constructs a new tooltip at given location with given content. - * The applied css class may be changed by the fourth parameter. - * - * @class - * @classdesc Stud.IP tooltips provide an improved layout and handling - * of contents (including html) than the browser's default - * tooltip through title attribute would - * - * @param {int} x - Horizontal position of the tooltip - * @param {int} y - Vertical position of the tooltip - * @param {string} content - Content of the tooltip (may be html) - * @param {string} css_class - Optional name of the applied css class / - * defaults to 'studip-tooltip' - */ - constructor(x, y, content, css_class) { - // Obtain unique id of the tooltip - this.id = Tooltip.getId(); - - // Create dom element of the tooltip, apply id and class and attach - // to dom - this.element = $('<div>'); - this.element.addClass(css_class || 'studip-tooltip'); - this.element.attr('id', this.id); - this.element.attr('role', 'tooltip'); - this.element.appendTo('body'); - - // Set position and content and paint the tooltip - this.position(x, y); - this.update(content); - this.paint(); - } - - /** - * Translates the arrow(s) under a tooltip using css3 translate - * transforms. This is needed at the edges of the screen. - * This implies that a current browser is used. The translation could - * also be achieved by adjusting margins but that way we would need - * to hardcode values into this function since it's a struggle to - * obtain the neccessary values from the CSS pseudo selectors in JS. - * - * Internal, css rules are dynamically created and applied to the current - * document by using the methods provided in the file studip-css.js. - * - * @param {int} x - Horizontal offset - * @param {int} y - Vertical offset - */ - translateArrows(x, y, left_arrow = false) { - CSS.removeRule(`#${this.id}::before`); - CSS.removeRule(`#${this.id}::after`); - - if (x !== 0 || y !== 0) { - let before_rule = { - transform: `translate(${x}px, ${y}px);` - }; - if (left_arrow) { - before_rule.transform = `translate(${x}px, ${y}px) rotate(90deg);`; - } - let after_rule = before_rule; - if (left_arrow) { - after_rule['border-width'] = '9px'; - } - CSS.addRule(`#${this.id}::before`, before_rule, ['-ms-', '-webkit-']); - CSS.addRule(`#${this.id}::after`, after_rule, ['-ms-', '-webkit-']); - } - } - - /** - * Updates the position of the tooltip. - * - * @param {int} x - Horizontal position of the tooltip - * @param {int} y - Vertical position of the tooltip - */ - position(x, y) { - this.x = x; - this.y = y; - } - - /** - * Updates the contents of the tooltip. - * - * @param {string} content - Content of the tooltip (may be html) - */ - update(content) { - this.element.html(content); - } - - /** - * "Paints" the tooltip. This method actually computes the dimensions of - * the tooltips, checks for screen edges and calculates the actual offset - * in the current document. - * This method is neccessary due to the fact that position and content - * can be changed apart from each other. - * Thus: Don't forget to repaint after adjusting any of the two. - */ - paint() { - const width = this.element.outerWidth(true); - const height = this.element.outerHeight(true); - const maxWidth = $(document).width(); - const maxHeight = $(document).height(); - let x = this.x - width / 2; - let y = this.y - height; - //The arrow offset is the offset from the bottom right corner of - //the tooltip "frame". - let arrow_offset_x = 0; - let arrow_offset_y = 0; - let left_arrow = false; - - if (y < 0) { - y = 0; - x = this.x + 20; - //Put the arrow on the left side and move the tooltip, - //if there is still enough place left on the right. - left_arrow = true; - arrow_offset_y = -height + this.y + 10; - if (arrow_offset_y > -20) { - y+= arrow_offset_y + 20; - arrow_offset_y = -20; - } - arrow_offset_x = -width / 2 - 8; - } else if (y + height > maxHeight) { - y = maxHeight - height; - } - - if (x < 0) { - arrow_offset_x = 0; - x = 0; - } else if (x + width > maxWidth) { - arrow_offset_x = x + width - maxWidth; - x = maxWidth - width; - } - this.translateArrows(arrow_offset_x, arrow_offset_y, left_arrow); - - this.element.css({ - left: x, - top: y - }); - } - - /** - * Toggles the visibility of the tooltip. If no state is provided, - * the tooltip will be hidden if visible and vice versa. Pretty straight - * forward and no surprises here. - * This method implicitely calls paint before a tooltip is shown (in case - * it was forgotten). - * - * @param {bool} visible - Optional visibility parameter to set the - * tooltip to a certain state - */ - toggle(visible) { - if (visible) { - this.paint(); - } - this.element.toggle(visible); - } - - /** - * Reveals the tooltip. - * - * @see Tooltip.toggle - */ - show() { - this.toggle(true); - } - - /** - * Hides the tooltip. - * - * @see Tooltip.toggle - */ - hide() { - this.toggle(false); - } - - /** - * Removes the tooltip - */ - remove() { - this.element.remove(); - } -} - -export default Tooltip; diff --git a/resources/assets/javascripts/lib/wysiwyg.js b/resources/assets/javascripts/lib/wysiwyg.js index f9acb81..47c64d0 100644 --- a/resources/assets/javascripts/lib/wysiwyg.js +++ b/resources/assets/javascripts/lib/wysiwyg.js @@ -13,17 +13,17 @@ const wysiwyg = { isHtml: function isHtml(text) { // NOTE keep this function in sync with - // Markup::isHtml in Markup.class.php + // Markup::isHtml in Markup.php return this.hasHtmlMarker(text); }, hasHtmlMarker: function hasHtmlMarker(text) { // NOTE keep this function in sync with - // Markup::hasHtmlMarker in Markup.class.php + // Markup::hasHtmlMarker in Markup.php return this.htmlMarkerRegExp.test(text); }, markAsHtml: function markAsHtml(text) { // NOTE keep this function in sync with - // Markup::markAsHtml in Markup.class.php + // Markup::markAsHtml in Markup.php if (this.hasHtmlMarker(text) || text.trim() == '') { return text; // marker already set, don't set twice } diff --git a/resources/assets/javascripts/mvv.js b/resources/assets/javascripts/mvv.js index a339624..92dcd68 100644 --- a/resources/assets/javascripts/mvv.js +++ b/resources/assets/javascripts/mvv.js @@ -67,10 +67,17 @@ jQuery(function ($) { }); $(document).on('click', '.stgfile .remove_attachment', function($event) { - STUDIP.MVV.Document.remove_attachment($(this)); + STUDIP.Dialog.confirm($gettext('Soll die Datei wirklich gelöscht werden?')).done(() => { + STUDIP.MVV.Document.remove_attachment(this); + }); return false; }); + $(document).on('click', '.stgfile .refresh_attachment', (event) => { + STUDIP.MVV.Document.refresh_attachment(event.target); + event.preventDefault(); + }); + STUDIP.dialogReady( function() { @@ -663,27 +670,55 @@ STUDIP.MVV.Document = { }) }, 100); }, - remove_attachment: function(item) { - jQuery.ajax({ - url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/materialien/files/delete_attachment', - data: { - mvvfile_id: jQuery('#mvvfile_id').val(), - fileref_id: item.closest('li') - .find('input[name=document_id]') - .val() - }, - type: 'POST' + refresh_attachment(item) { + const language = item.closest('button').dataset.language; + const document_id = item.closest('.stgfile').querySelector('[name="document_id"]').value; + + const input = document.createElement('input'); + input.type = 'file'; + input.hidden = true; + + input.addEventListener('cancel', () => { + input.remove(); }); - item.parents('td').find('.attachments').toggle(); - item.closest('li') - .fadeOut(300, function() { - jQuery(this).remove(); + input.addEventListener('change', () => { + const fd = new FormData(); + fd.append('file', input.files[0], input.files[0].name); + fd.append('mvvfile_id', jQuery('#mvvfile_id').val()); + fd.append('range_id', jQuery('#range_id').val()); + fd.append('document_id', document_id); + fd.append('file_language', language); + + const statusbar = $('#statusbar_container .statusbar') + .first() + .clone() + .show(); + statusbar.appendTo('#statusbar_container'); + + STUDIP.MVV.Document.upload_file(fd, statusbar, true).then(() => { + input.remove(); + }); + }); + + item.parentNode.after(input); + input.click(); + }, + remove_attachment(item) { + const url = STUDIP.URLHelper.getURL('dispatch.php/materialien/files/delete_attachment', { + mvvfile_id: document.getElementById('mvvfile_id').value, + fileref_id: item.closest('li').querySelector('input[name=document_id]').value, + }); + $.post(url).done(() => { + $(item).closest('td').find('.attachments').toggle(); + $(item).closest('li').fadeOut(300, function () { + this.remove(); jQuery('#upload_chooser').show(); }); + }); }, - upload_from_input: function(input, file_language) { + upload_from_input(input, file_language) { STUDIP.MVV.Document.upload_files(input.files, file_language); - jQuery(input).val(''); + input.value = ''; }, fileIDQueue: 1, upload_files: function(files, file_language) { @@ -701,82 +736,94 @@ STUDIP.MVV.Document = { STUDIP.MVV.Document.upload_file(fd, statusbar); } }, - upload_file: function(formdata, statusbar) { - $.ajax({ - xhr: function() { - var xhrobj = $.ajaxSettings.xhr(); - if (xhrobj.upload) { - xhrobj.upload.addEventListener( - 'progress', - function(event) { - var percent = 0; - var position = event.loaded || event.position; - var total = event.total; - if (event.lengthComputable) { - percent = Math.ceil((position / total) * 100); - } - //Set progress - statusbar.find('.progress').css({ 'min-width': percent + '%', 'max-width': percent + '%' }); - statusbar - .find('.progresstext') - .text(percent === 100 ? jQuery('#upload_finished').text() : percent + '%'); - }, - false - ); + upload_file(formdata, statusbar, update = false) { + return new Promise((resolve, reject) => { + $.ajax({ + xhr() { + var xhrobj = $.ajaxSettings.xhr(); + if (xhrobj.upload) { + xhrobj.upload.addEventListener( + 'progress', + function(event) { + var percent = 0; + var position = event.loaded || event.position; + var total = event.total; + if (event.lengthComputable) { + percent = Math.ceil((position / total) * 100); + } + //Set progress + statusbar.find('.progress').css({ 'min-width': percent + '%', 'max-width': percent + '%' }); + statusbar + .find('.progresstext') + .text(percent === 100 ? jQuery('#upload_finished').text() : percent + '%'); + }, + false + ); + } + return xhrobj; + }, + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/materialien/files/upload_attachment', + type: 'POST', + contentType: false, + processData: false, + cache: false, + data: formdata, + dataType: 'json' + }).done((data) => { + const language = formdata.get('file_language'); + + statusbar.find('.progress').css({ 'min-width': '100%', 'max-width': '100%' }); + var file = jQuery('#fileselector_'+formdata.get('file_language')).find('.stgfiles > .stgfile') + .first() + .clone(); + file.find('.name').text(data.name); + if (data.size < 1024) { + file.find('.size').text(data.size + 'B'); } - return xhrobj; - }, - url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/materialien/files/upload_attachment', - type: 'POST', - contentType: false, - processData: false, - cache: false, - data: formdata, - dataType: 'json' - }) - .done(function(data) { - statusbar.find('.progress').css({ 'min-width': '100%', 'max-width': '100%' }); - var file = jQuery('#fileselector_'+formdata.get('file_language')).find('.stgfiles > .stgfile') - .first() - .clone(); - file.find('.name').text(data.name); - if (data.size < 1024) { - file.find('.size').text(data.size + 'B'); - } - if (data.size > 1024 && data.size < 1024 * 1024) { - file.find('.size').text(Math.floor(data.size / 1024) + 'KB'); - } - if (data.size > 1024 * 1024 && data.size < 1024 * 1024 * 1024) { - file.find('.size').text(Math.floor(data.size / 1024 / 1024) + 'MB'); - } - if (data.size > 1024 * 1024 * 1024) { - file.find('.size').text(Math.floor(data.size / 1024 / 1024 / 1024) + 'GB'); - } - file.find('.icon').html(data.icon); - file.find('input[name=document_id]').attr('value', data.document_id); - jQuery('#fileviewer_'+formdata.get('file_language')).find('.stgfiles').append(file); - jQuery('#fileselector_'+formdata.get('file_language')).toggle(); - jQuery('#fileselector_'+formdata.get('file_language')).parents('.attachments').toggle(); - jQuery('#fileselector_'+formdata.get('file_language')).parents('.attachments').find('span').toggle(); - file.fadeIn(300); - statusbar.find('.progresstext').text(jQuery('#upload_received_data').text()); - statusbar.delay(1000).fadeOut(300, function() { - jQuery('#upload_chooser').hide(); - jQuery(this).remove(); - }); - }) - .fail(function(jqxhr, status, errorThrown) { - var error = jqxhr.responseJSON.error; - - statusbar - .find('.progress') - .addClass('progress-error') - .attr('title', error); - statusbar.find('.progresstext').html(error); - statusbar.on('click', function() { - jQuery(this).fadeOut(300, function() { - jQuery(this).remove(); + if (data.size > 1024 && data.size < 1024 * 1024) { + file.find('.size').text(Math.floor(data.size / 1024) + 'KB'); + } + if (data.size > 1024 * 1024 && data.size < 1024 * 1024 * 1024) { + file.find('.size').text(Math.floor(data.size / 1024 / 1024) + 'MB'); + } + if (data.size > 1024 * 1024 * 1024) { + file.find('.size').text(Math.floor(data.size / 1024 / 1024 / 1024) + 'GB'); + } + file.find('.icon').html(data.icon); + file.find('input[name=document_id]').attr('value', data.document_id); + if (update) { + $(`#fileviewer_${language} .stgfiles`).empty().append(file); + file.show(); + } else { + $(`#fileviewer_${language}`).find('.stgfiles').append(file); + $(`#fileselector_${language}`) + .toggle() + .parents('.attachments').toggle() + .find('span').toggle(); + file.fadeIn(300); + } + statusbar.find('.progresstext').text(jQuery('#upload_received_data').text()); + statusbar.delay(1000).fadeOut(300, function() { + $('#upload_chooser').hide(); + this.remove(); }); + + resolve(); + }).fail(function(jqxhr, status, errorThrown) { + var error = jqxhr.responseJSON.error; + + statusbar + .find('.progress') + .addClass('progress-error') + .attr('title', error); + statusbar.find('.progresstext').html(error); + statusbar.on('click', function() { + jQuery(this).fadeOut(300, function() { + jQuery(this).remove(); + }); + }); + + reject(new Error(error)); }); }); } diff --git a/resources/assets/javascripts/studip-ui.js b/resources/assets/javascripts/studip-ui.js index f581295..f98ba94 100644 --- a/resources/assets/javascripts/studip-ui.js +++ b/resources/assets/javascripts/studip-ui.js @@ -1,5 +1,6 @@ import { $gettext } from './lib/gettext'; import eventBus from "./lib/event-bus.ts"; +import RestrictedDatesHelper from './lib/RestrictedDatesHelper'; /** * This file contains extensions/adjustments for jQuery UI. @@ -28,33 +29,11 @@ import eventBus from "./lib/event-bus.ts"; } function disableHolidaysBeforeShow(date) { - const year = date.getFullYear(); - - if (STUDIP.UI.restrictedDates[year] === undefined) { - STUDIP.UI.restrictedDates[year] = {}; - - STUDIP.jsonapi.GET('holidays', {data: { - 'filter[year]': year - }}).done(response => { - // Since PHP will return an empty object as an array, - // we need to check - if (Array.isArray(response)) { - return; - } - - for (const [date, data] of Object.entries(response)) { - STUDIP.UI.addRestrictedDate( - new Date(date), - data.holiday, - data.mandatory - ); - } - - $(this).datepicker('refresh'); - }); - } - - const {reason, lock} = STUDIP.UI.isDateRestricted(date, false); + RestrictedDatesHelper.loadRestrictedDatesByYear(date.getFullYear()).then( + () => $(this).datepicker('refresh'), + () => null + ); + const {reason, lock} = RestrictedDatesHelper.isDateRestricted(date); return [!lock, lock ? 'ui-datepicker-is-locked' : null, reason]; } @@ -83,57 +62,8 @@ import eventBus from "./lib/event-bus.ts"; return; } + STUDIP.UI = {}; // Setup Stud.IP's own datepicker extensions - STUDIP.UI = Object.assign(STUDIP.UI || {}, { - restrictedDates: {}, - addRestrictedDate(date, reason = '', lock = true) { - if (this.isDateRestricted(date)) { - return; - } - - const [year, month, day] = this.convertDateForRestriction(date); - if (this.restrictedDates[year] === undefined) { - this.restrictedDates[year] = {}; - } - if (this.restrictedDates[year][month] === undefined) { - this.restrictedDates[year][month] = {}; - } - - this.restrictedDates[year][month][day] = {reason, lock}; - }, - removeRestrictedDate(date) { - if (!this.isDateRestricted(date)) { - return false; - } - const [year, month, day] = this.convertDateForRestriction(date); - - delete this.restrictedDates[year][month][day]; - - if (Object.keys(this.restrictedDates[year][month]).length === 0) { - delete this.restrictedDates[year][month]; - } - - return true; - }, - isDateRestricted(date, return_bool = true) { - const [year, month, day] = this.convertDateForRestriction(date); - if ( - this.restrictedDates[year] === undefined - || this.restrictedDates[year][month] === undefined - || this.restrictedDates[year][month][day] === undefined - ) { - return return_bool ? false : { - reason: null, - lock: false, - }; - } - - return return_bool ? true : this.restrictedDates[year][month][day]; - }, - convertDateForRestriction(date) { - return [date.getFullYear(), date.getMonth() + 1, date.getDate()]; - } - }); STUDIP.UI.Datepicker = { selector: '.has-date-picker,[data-date-picker]', // Initialize all datepickers that not yet been initialized (e.g. in dialogs) diff --git a/resources/assets/stylesheets/highcontrast.scss b/resources/assets/stylesheets/highcontrast.scss index 4e1e4ef..277e383 100644 --- a/resources/assets/stylesheets/highcontrast.scss +++ b/resources/assets/stylesheets/highcontrast.scss @@ -1309,3 +1309,18 @@ form.default { border: 1px solid var(--black); } } + +.studip-tree-course { + .course-dates { + color: var(--black) !important; + + } +} + +div.avatar-widget { + .profile-avatar { + .avatar-overlay { + background-color: fade-out($base-color, 0.1); + } + } +} diff --git a/resources/assets/stylesheets/mixins/misc.scss b/resources/assets/stylesheets/mixins/misc.scss index 90d61c5..1fe9d08 100644 --- a/resources/assets/stylesheets/mixins/misc.scss +++ b/resources/assets/stylesheets/mixins/misc.scss @@ -15,10 +15,6 @@ clear: both; } } -@mixin list-unstyled { - padding-left: 0; - list-style: none; -} @mixin size($height, $width) { diff --git a/resources/assets/stylesheets/mixins/studip.scss b/resources/assets/stylesheets/mixins/studip.scss index 344f802..1bbd7d5 100644 --- a/resources/assets/stylesheets/mixins/studip.scss +++ b/resources/assets/stylesheets/mixins/studip.scss @@ -259,19 +259,3 @@ /* Opera doesn't support this in the shorthand */ background-attachment: local, local, scroll, scroll; } - -@mixin list-unstyled { - padding-left: 0; - list-style: none; -} - -@mixin list-inline { - @include list-unstyled(); - margin-left: -5px; - - > li { - display: inline-block; - padding-left: 5px; - padding-right: 5px; - } -} diff --git a/resources/assets/stylesheets/print.scss b/resources/assets/stylesheets/print.scss index bace5fb..7f6c043 100644 --- a/resources/assets/stylesheets/print.scss +++ b/resources/assets/stylesheets/print.scss @@ -3,7 +3,6 @@ @import "scss/visibility"; @import "scss/fullcalendar-print"; @import "scss/resources-print"; -@import "scss/schedule" ; /******************************************************************************* diff --git a/resources/assets/stylesheets/scss/admin-courses.scss b/resources/assets/stylesheets/scss/admin-courses.scss index 753e56b..09ec6ca 100644 --- a/resources/assets/stylesheets/scss/admin-courses.scss +++ b/resources/assets/stylesheets/scss/admin-courses.scss @@ -29,10 +29,6 @@ } } -#admin-filter-widget .label-text { - display: block; -} - .action-menu.filter { margin-left: 1em; } diff --git a/resources/assets/stylesheets/scss/blubber.scss b/resources/assets/stylesheets/scss/blubber.scss index 1893fd5..76bafe4 100644 --- a/resources/assets/stylesheets/scss/blubber.scss +++ b/resources/assets/stylesheets/scss/blubber.scss @@ -1,7 +1,7 @@ .blubber_panel { display: flex; align-items: stretch; - height: calc(100vh - 174px); + height: calc(100vh - 130px); transition: opacity 100ms, filter 100ms; &.waiting { filter: blur(1px); @@ -447,15 +447,12 @@ } -form.default { - .blubber_composer_select_container { - input, select, .container { - width: calc(100% - 50px); - display: inline-block; - } +.blubber_composer_select_container { + input, select, .container { + width: 90%; + display: inline-block; } } - .float_right { float: right; } @@ -523,4 +520,7 @@ ol.tagcloud { .blubber_thread { margin-right: 0; } + .blubber_threads_widget .sidebar-widget-content { + max-height: calc(100vh - 230px); + } } diff --git a/resources/assets/stylesheets/scss/buttons.scss b/resources/assets/stylesheets/scss/buttons.scss index 6c5d070..c148b72 100644 --- a/resources/assets/stylesheets/scss/buttons.scss +++ b/resources/assets/stylesheets/scss/buttons.scss @@ -140,7 +140,8 @@ button, .button { &.as-link, &.styleless, - &.undecorated { + &.undecorated, + &.icon-button { background-color: unset; border: 0; } diff --git a/resources/assets/stylesheets/scss/contents.scss b/resources/assets/stylesheets/scss/contents.scss index fc8a6c9..8e02284 100644 --- a/resources/assets/stylesheets/scss/contents.scss +++ b/resources/assets/stylesheets/scss/contents.scss @@ -8,7 +8,7 @@ width: 100%; .content-item { - height: 100px; + min-height: 100px; .content-item-link { padding: 5px; @@ -46,7 +46,7 @@ background-color: var(--dark-gray-color-5); border: solid thin var(--light-gray-color-40); display: flex; - height: 150px; + min-height: 150px; justify-content: stretch; .content-item-link { diff --git a/resources/assets/stylesheets/scss/copyable-links.scss b/resources/assets/stylesheets/scss/copyable-links.scss deleted file mode 100644 index f5ff73a..0000000 --- a/resources/assets/stylesheets/scss/copyable-links.scss +++ /dev/null @@ -1,24 +0,0 @@ -.copyable-link-confirmation { - position: fixed; - bottom: 60px; - right: 12px; - height: 60px; - line-height: 60px; - max-width: calc(100% - 140px); - z-index: 42000; - border: solid thin var(--content-color-40); - background-color: var(--white); - background-repeat: no-repeat; - background-position: 1em center; - background-size: 100px; - box-shadow: 5px 5px var(--dark-gray-color-10); - padding: 5px 100px; - transition: transform .5s ease; - - &.copyable-link-success { - @include background-icon(check-circle, status-green, 24); - } - &.copyable-link-error { - @include background-icon(decline-circle, status-red, 24); - } -} diff --git a/resources/assets/stylesheets/scss/courseware/blockadder.scss b/resources/assets/stylesheets/scss/courseware/blockadder.scss index 93325dd..f0e993a 100644 --- a/resources/assets/stylesheets/scss/courseware/blockadder.scss +++ b/resources/assets/stylesheets/scss/courseware/blockadder.scss @@ -7,6 +7,7 @@ .cw-blockadder-item-wrapper { display: flex; + position: relative; border: solid thin var(--content-color-40); max-width: 268px; @@ -54,6 +55,7 @@ } .cw-containeradder-item-wrapper { + position: relative; border: solid thin var(--content-color-40); margin-bottom: 4px; .cw-sortable-handle { @@ -136,7 +138,7 @@ } .cw-element-inserter-wrapper { display: grid; - grid-template-columns: repeat(auto-fit, minmax(225px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-auto-rows: auto; grid-gap: 4px; margin-bottom: 8px; diff --git a/resources/assets/stylesheets/scss/courseware/blocks/document.scss b/resources/assets/stylesheets/scss/courseware/blocks/document.scss index fb230b2..0c69cda 100644 --- a/resources/assets/stylesheets/scss/courseware/blocks/document.scss +++ b/resources/assets/stylesheets/scss/courseware/blocks/document.scss @@ -109,6 +109,7 @@ .cw-pdf-outer-container { position: relative; width: 100%; + overflow: hidden; .cw-pdf-content { display: flex; diff --git a/resources/assets/stylesheets/scss/courseware/toolbar.scss b/resources/assets/stylesheets/scss/courseware/toolbar.scss index fdabbf2..0670486 100644 --- a/resources/assets/stylesheets/scss/courseware/toolbar.scss +++ b/resources/assets/stylesheets/scss/courseware/toolbar.scss @@ -14,8 +14,7 @@ min-height: 100%; border: solid thin var(--content-color-40); background-color: var(--white); - overflow-y: auto; - overflow-x: hidden; + overflow: hidden; position: relative; padding: 0 4px; top: 0; @@ -33,6 +32,11 @@ right: 0; } + .cw-toolbar-tool-content { + overflow-y: auto; + padding-right: 8px; + } + .cw-toolbar-blocks { .input-group.files-search { &.search { @@ -96,6 +100,12 @@ } } + + .cw-toolbar-clipboard { + .cw-collapsible { + margin-bottom: 4px; + } + } } .cw-toolbar-folded-wrapper { display: flex; @@ -124,6 +134,7 @@ &.cw-toolbar-button-toggle { text-align: end; + flex-grow: 1; } &.active { @@ -142,7 +153,7 @@ .cw-toolbar-tools.hd { .cw-toolbar-button-wrapper { .cw-toolbar-button { - width: 128px; + min-width: 110px; padding: 2px 16px 0 16px; &.cw-toolbar-button-toggle { text-align: end; diff --git a/resources/assets/stylesheets/scss/dashboard.scss b/resources/assets/stylesheets/scss/dashboard.scss index 6020d6e..e353f72 100644 --- a/resources/assets/stylesheets/scss/dashboard.scss +++ b/resources/assets/stylesheets/scss/dashboard.scss @@ -77,7 +77,7 @@ padding-bottom: 1em; ul { - @include list-inline(); + @extend .list-inline; img { margin-left: 0.25em; diff --git a/resources/assets/stylesheets/scss/evaluation.scss b/resources/assets/stylesheets/scss/evaluation.scss deleted file mode 100644 index 63fcef6..0000000 --- a/resources/assets/stylesheets/scss/evaluation.scss +++ /dev/null @@ -1,40 +0,0 @@ -/* classes for the evaluation modules in Stud.IP ---------------------------- */ -.eval_title { - font-size: 1.2em; - font-weight: bold; - color: var(--base-color); -} - -.eval_error { - color: var(--red); -} - -.eval_success { - color: var(--green); -} - -.eval_info { - color: var(--base-gray); -} - -.eval_metainfo { - font-size: 0.8em; -} - -.eval_highlight { - background-color: var(--content-color-60); -} - -.eval_gray { - background: var(--dark-gray-color-20) none; -} -.evaluation_item { - box-sizing: border-box; - margin: 3px; -} - -h3.eval { - font-size: 1.3em; - color: var(--black); - font-weight: bold; -} diff --git a/resources/assets/stylesheets/scss/files.scss b/resources/assets/stylesheets/scss/files.scss index 7808f15..a75f8f3 100644 --- a/resources/assets/stylesheets/scss/files.scss +++ b/resources/assets/stylesheets/scss/files.scss @@ -406,19 +406,20 @@ form.default { display: flex; justify-content: space-between; align-items: center; - padding: 6px 6px 2px; + padding: 0 10px 0; margin-bottom: 0; border-top: none; > .text { width: 100%; margin-left: 10px; } - > .arrow { - margin-right: 5px; - } > .check { display: none; } + + > .icon { + margin-top: 6px; + } } > label:first-of-type { border-top: 1px solid var(--content-color-40); diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss index c647ee8..888cf56 100644 --- a/resources/assets/stylesheets/scss/forms.scss +++ b/resources/assets/stylesheets/scss/forms.scss @@ -621,6 +621,28 @@ form.inline { } } +.studip-dialog { + form[data-vue-app] { + display: flex; + flex-direction: column; + min-height: 100%; + + fieldset { + flex: 0; + } + + footer[data-dialog-button] { + background: var(--white); + border-top-color: var(--base-color-20); + bottom: -0.5em; + margin-top: auto; + padding: 1.3em 0; + position: sticky; + text-align: center; + } + } +} + @media (min-width: 800px) { form.default .form-columns { display: flex; diff --git a/resources/assets/stylesheets/scss/globalsearch.scss b/resources/assets/stylesheets/scss/globalsearch.scss index 4faee5f..ef7c6e6 100644 --- a/resources/assets/stylesheets/scss/globalsearch.scss +++ b/resources/assets/stylesheets/scss/globalsearch.scss @@ -171,7 +171,7 @@ } } - section { + a[role=listitem] { display: flex; flex-direction: row; flex-wrap: nowrap; @@ -189,7 +189,7 @@ display: none; } - & > a { + & > span.detail { display: flex; flex-direction: row; flex-wrap: nowrap; diff --git a/resources/assets/stylesheets/scss/lists.scss b/resources/assets/stylesheets/scss/lists.scss index 8ef1eb7..80796b7 100644 --- a/resources/assets/stylesheets/scss/lists.scss +++ b/resources/assets/stylesheets/scss/lists.scss @@ -10,9 +10,25 @@ ol { } } +.list-unstyled { + padding-left: 0; + list-style: none; +} + +.list-inline { + @extend .list-unstyled; + margin-left: -5px; + + > li { + display: inline-block; + padding-left: 5px; + padding-right: 5px; + } +} + //comma separated .list-csv { - @include list-inline(); + @extend .list-inline; margin-left: 0; > li { @@ -22,8 +38,11 @@ ol { content: ","; } - &:last-child::after { - content: unset; + &:last-child { + padding-right: 0; + &::after { + content: unset; + } } } @@ -35,7 +54,7 @@ ol { } .list-pipe-separated { - @include list-inline(); + @extend .list-inline; display: flex; // Prevents the mystery gap between elements > li { @@ -47,6 +66,19 @@ ol { } } +.list-slash-separated-small { + @extend .list-csv; + + > li { + padding-right: 0; + font-size: small; + + &::after { + content: "/"; + } + } +} + dl { dt { font-weight: bold; diff --git a/resources/assets/stylesheets/scss/messagebox.scss b/resources/assets/stylesheets/scss/messagebox.scss index a928bb6..7f77f0f 100644 --- a/resources/assets/stylesheets/scss/messagebox.scss +++ b/resources/assets/stylesheets/scss/messagebox.scss @@ -72,7 +72,7 @@ div.messagebox_details { // Define modal messagebox .modaloverlay { - background: fadeout($base-color, 50%); + background: fade-out($base-color, 0.5); position: fixed; top: 0; left: 0; diff --git a/resources/assets/stylesheets/scss/mvv.scss b/resources/assets/stylesheets/scss/mvv.scss index c93b214..ce85701 100644 --- a/resources/assets/stylesheets/scss/mvv.scss +++ b/resources/assets/stylesheets/scss/mvv.scss @@ -253,22 +253,6 @@ ul { dd { margin: 0; } - - &.even { - background-color: var(--dark-gray-color-10); - - &:hover { - background-color: var(--content-color-60); - } - } - - &.odd { - background-color: var(--dark-gray-color-5); - - &:hover { - background-color: var(--content-color-40); - } - } } &.mvv-modul li { @@ -559,14 +543,6 @@ form.default .mvv-inst-chooser select { h3 { margin-top: 1em; } - - &.odd { - background-color: var(--dark-gray-color-5); - } - - &.even { - background-color: var(--content-color-20); - } } #lvgruppe_selection_chosen { diff --git a/resources/assets/stylesheets/scss/my_courses.scss b/resources/assets/stylesheets/scss/my_courses.scss index b82ec2b..769f3d2 100644 --- a/resources/assets/stylesheets/scss/my_courses.scss +++ b/resources/assets/stylesheets/scss/my_courses.scss @@ -9,7 +9,7 @@ background: var(--white); } -.mycourses-group-selector { +form.default .mycourses-group-selector { position: relative; background-clip: padding-box; @@ -19,26 +19,41 @@ @extend .sr-only; &:checked + label { - @include background-icon(accept, info); + .group-number { + display: none; + } + .checked-icon { + display: inline; + } } } &:hover label { - @include background-icon(accept, info); + .group-number { + display: none; + } + .checked-icon { + display: inline; + } } label { - @include hide-text(); + text-align: center; + font-size: large; + font-weight: bold; + cursor: pointer; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; + background-color: var(--white); + margin-bottom: 0; + text-indent: 0; - background-position: center; - background-repeat: no-repeat; + height: 1.2em; - cursor: pointer; + .group-number { + display: inline; + } + .checked-icon { + display: none; + } } } diff --git a/resources/assets/stylesheets/scss/profile.scss b/resources/assets/stylesheets/scss/profile.scss index ef7e75a..c180e03 100644 --- a/resources/assets/stylesheets/scss/profile.scss +++ b/resources/assets/stylesheets/scss/profile.scss @@ -13,7 +13,7 @@ padding: 0 1em; } .profile-view-actions { - @include list-unstyled(); + @extend .list-unstyled; img { vertical-align: text-top; } diff --git a/resources/assets/stylesheets/scss/questionnaire.scss b/resources/assets/stylesheets/scss/questionnaire.scss index f5e726b..fde7d32 100644 --- a/resources/assets/stylesheets/scss/questionnaire.scss +++ b/resources/assets/stylesheets/scss/questionnaire.scss @@ -1,8 +1,6 @@ $width: 270px; .questionnaire_edit { - - .editor { display: flex; flex-direction: row-reverse; @@ -14,14 +12,16 @@ $width: 270px; min-width: $width; width: $width; .questions_container { - padding: 0px; + padding: 0; .questions { display: flex; flex-direction: column; } } - > .admin, > .add_question, .questions > * { + > .admin, + > .add_question, + .questions > * { width: calc(100% - 8px); padding: 4px; border-bottom: 1px solid var(--content-color-40); @@ -42,8 +42,8 @@ $width: 270px; &::before { content: ''; position: absolute; - height: 0px; - width: 0px; + height: 0; + width: 0; border-top: 25px transparent solid; border-bottom: 25px transparent solid; border-left: 7px var(--content-color-40) solid; @@ -52,8 +52,8 @@ $width: 270px; &::after { content: ''; position: absolute; - height: 0px; - width: 0px; + height: 0; + width: 0; border-top: 25px transparent solid; border-bottom: 25px transparent solid; border-left: 7px var(--yellow-40) solid; @@ -93,42 +93,11 @@ $width: 270px; border: 1px solid var(--content-color-40); border-left: none; flex-grow: 1; - padding: 10px; - padding-left: 15px; + padding: 10px 10px 10px 15px; min-height: 150px; min-width: 0; } - .vote_edit { - .options { - > li { - display: flex; - align-items: center; - > * { - margin-right: 10px; - } - } - } - } - .rangescale_edit table.default > thead > tr > th.number { - padding-left: 12px; - } - - .dragcolumn { - max-width: 1px; - padding-bottom: 0px; - > .dragarea { - display: inline-block; - height: 27px; - } - } - - .input-array { - margin-left: 4px; - } - .likert_edit .input-array { - margin-left: 7px; - } .inline_editing { width: 100%; display: flex; @@ -150,114 +119,25 @@ $width: 270px; justify-items: center; } } - .drag-handle { - display: inline-block; - height: 24px; - } - } - /* ab hier der alte kram */ - - section { - border: thin solid var(--black); - margin: 3px; - } - - .options { - padding: 0; - list-style-type: none; - - > li { - margin-top: 5px; - margin-bottom: 5px; - - > .move { - cursor: move; - display: inline-block; - vertical-align: middle; - } - - > input { - display: inline-block; - vertical-align: middle; - } - - > input[type=text] { - width: calc(100% - 70px); - } - - .delete { + .dragcolumn { + max-width: 1px; + padding-bottom: 0; + > .dragarea { display: inline-block; - vertical-align: middle; - cursor: pointer; - } - - .add { - display: none; - vertical-align: middle; - cursor: pointer; + height: 27px; } } - > li:last-child .delete { - display: none; - } - - > li:last-child .add { + .drag-handle { display: inline-block; + height: 24px; } - > li:only-child .move { - display: none; - } - - } - - .all_questions { - .question:first-child .move_up { - display: none; - } - - .question:last-child .move_down { - display: none; - } - } - - .add_questions { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: stretch; - border: thin dashed var(--content-color-40); - - > a { - background-color: transparent; - margin: 10px; - border: thin solid var(--content-color-20); - padding: 5px; - width: 100px; - min-width: 100px; - max-width: 100px; - height: 100px; - min-height: 100px; - max-height: 100px; - overflow: hidden; - display: flex; - flex-direction: column; - justify-content: space-around; - align-items: center; + .option-cell { text-align: center; - - > img { - margin-left: auto; - margin-right: auto; - } } } - - .questionnaire_metadata { - margin-top: 10px; - } } .questionnaire_results { @@ -308,7 +188,8 @@ $width: 270px; } -.questionnaire_answer, .questionnaire_results { +.questionnaire_answer, +.questionnaire_results { .description_container { display: flex; > .icon_container { @@ -335,7 +216,7 @@ $width: 270px; border: none; > :first-child { - margin-top: 0px; + margin-top: 0; } .invalidation_notice { @@ -351,9 +232,6 @@ $width: 270px; font-size: 0.7em; padding-left: 5px; } - .rangescale_center { - text-align: center; - } .centerline { border-top: 1px solid var(--base-color); position: relative; @@ -387,39 +265,13 @@ $width: 270px; } } } +} - .centerline { - border-top: 1px solid var(--base-color); - position: relative; - top: 35px; - margin-left: -5px; - margin-right: -5px; - z-index: 2; - } - .questionnaire-evaluation-circle-container { +.questionnaire_edit, +.questionnaire_answer, +.questionnaire_results { + .option-cell { text-align: center; - display: block; - .questionnaire-evaluation-circle { - width: 70px; - height: 70px; - display: flex; - justify-content: center; - align-items: center; - margin-left: auto; - margin-right: auto; - z-index: 3; - position: relative; - > .value { - border-radius: 100px; - color: white; - display: flex; - justify-content: center; - align-items: center; - background-color: var(--base-color); - width: 100%; - height: 100%; - } - } } } diff --git a/resources/assets/stylesheets/scss/responsive.scss b/resources/assets/stylesheets/scss/responsive.scss index c4317e9..e84a8a8 100644 --- a/resources/assets/stylesheets/scss/responsive.scss +++ b/resources/assets/stylesheets/scss/responsive.scss @@ -537,6 +537,26 @@ $sidebarOut: -330px; } } } + + #system-notifications { + top: 0; + position: fixed; + left: 0; + width: 100%; + z-index: 1001; + } + + .system-notification { + &.system-notification-slide-enter, + &.system-notification-slide-leave-to { + transform: translateY(-100%); + } + + &.system-notification-slide-leave, + &.system-notification-slide-enter-to { + transform: translateY(0); + } + } } /* Settings especially for fullscreen mode */ diff --git a/resources/assets/stylesheets/scss/schedule.scss b/resources/assets/stylesheets/scss/schedule.scss index c8c8f5a..a09e3ad 100644 --- a/resources/assets/stylesheets/scss/schedule.scss +++ b/resources/assets/stylesheets/scss/schedule.scss @@ -185,10 +185,6 @@ td.schedule-adminbind { } #color_picker { - div { - display: flex; - flex-wrap: wrap; - } span { flex: 0 0 auto; @@ -198,7 +194,7 @@ td.schedule-adminbind { } input[type="radio"] { - display: none; + @extend .sr-only; &:checked + label { outline: 1px solid var(--black); @@ -266,6 +262,15 @@ td.schedule-adminbind { &.schedule-category15 { background-color: $calendar-category-15; } + &.schedule-category16 { + background-color: $calendar-category-16; + } + &.schedule-category17 { + background-color: $calendar-category-17; + } + &.schedule-category18 { + background-color: $calendar-category-18; + } &.schedule-category255 { background-color: $calendar-category-255; } @@ -440,5 +445,38 @@ div.schedule_entry { color: contrast($calendar-category-15-aux, $black, $white); } } + &.schedule-category16 { + background-color: $calendar-category-16-aux; + border: 1px solid $calendar-category-16; + dt { + background-color: $calendar-category-16; + color: contrast($calendar-category-16, $black, $white); + } + dd { + color: contrast($calendar-category-16-aux, $black, $white); + } + } + &.schedule-category17 { + background-color: $calendar-category-17-aux; + border: 1px solid $calendar-category-17; + dt { + background-color: $calendar-category-17; + color: contrast($calendar-category-17, $black, $white); + } + dd { + color: contrast($calendar-category-17-aux, $black, $white); + } + } + &.schedule-category18 { + background-color: $calendar-category-18-aux; + border: 1px solid $calendar-category-18; + dt { + background-color: $calendar-category-18; + color: contrast($calendar-category-18, $black, $white); + } + dd { + color: contrast($calendar-category-18-aux, $black, $white); + } + } } } diff --git a/resources/assets/stylesheets/scss/sidebar.scss b/resources/assets/stylesheets/scss/sidebar.scss index 9a97976..c161778 100644 --- a/resources/assets/stylesheets/scss/sidebar.scss +++ b/resources/assets/stylesheets/scss/sidebar.scss @@ -313,6 +313,7 @@ select.sidebar-selectlist { flex: 1; padding: .25em .5em; width: 100%; + order: 1; } .submit-search { @@ -324,17 +325,22 @@ select.sidebar-selectlist { cursor: pointer; font: 0/0 a; text-shadow: none; + order: 3; } .reset-search { - background-color: transparent; - border: 1px solid var(--dark-gray-color-30); - border-left: 0; - border-right: 0; + background: unset; display: inline-block; - padding-right: 5px; - padding-top: 4px; cursor: pointer; + order: 2; + height: 100%; + box-sizing: border-box; + margin-right: 2px; + margin-left: -22px; + + img { + padding-top: 4px; + } } } diff --git a/resources/assets/stylesheets/scss/system-notifications.scss b/resources/assets/stylesheets/scss/system-notifications.scss new file mode 100644 index 0000000..34c341b --- /dev/null +++ b/resources/assets/stylesheets/scss/system-notifications.scss @@ -0,0 +1,178 @@ +#system-notifications { + &.bottom-right { + bottom: 50px; + right: 15px; + + .system-notification { + box-shadow: 5px 5px var(--dark-gray-color-10); + + &.system-notification-slide-enter, + &.system-notification-slide-leave-to { + opacity: 0; + transform: translateX(100%); + } + + &.system-notification-slide-leave, + &.system-notification-slide-enter-to { + opacity: 1; + transform: translateX(0); + } + } + } + + &.top-center { + left: calc(50% - 300px); + top: 0; + + .system-notification { + box-shadow: 0 0 0 3px var(--dark-gray-color-10); + + &.system-notification-slide-enter, + &.system-notification-slide-leave-to { + opacity: 0; + transform: translateY(-100%); + } + + &.system-notification-slide-leave, + &.system-notification-slide-enter-to { + opacity: 1; + transform: translateY(0); + } + } + } + + &:not(.system-notifications-login) { + position: fixed; + width: 600px; + } + + &.system-notifications-login { + margin-bottom: 15px; + } + + overflow: hidden; + z-index: 1001; +} + +.system-notification { + background-color: var(--content-color-20); + border: thin solid var(--content-color-40); + color: var(--black); + cursor: pointer; + display: flex; + padding: 10px; + position: relative; + + &.system-notification-slide-enter-active, + &.system-notification-slide-leave-active { + transition: all var(--transition-duration-slow) ease-in-out; + } + + .system-notification-icon { + flex: 0; + padding: 10px; + } + + .system-notification-content { + flex: 1; + height: auto; + margin: auto 25px auto 10px; + word-wrap: break-word; + + .system-notification-details { + .system-notification-details-content { + padding-left: 10px; + } + } + } + + .system-notification-close { + align-self: normal; + flex: 0; + height: 20px; + width: 20px; + + img, svg { + cursor: pointer; + position: absolute; + right: 10px; + top: 10px; + } + } + + .system-notification-timeout { + &.system-notification-timeout-enter-active, + &.system-notification-timeout-leave-active { + transition: width 5s linear; + } + + &.system-notification-timeout-enter { + width: 100%; + } + + &.system-notification-timeout-leave { + width: 0; + } + + background-color: var(--base-color-40); + bottom: 0; + height: 5px; + left: 0; + position: absolute; + width: 0; + } + + &.system-notification-disrupted .system-notification-timeout { + display: none; + } + + a:not(.system-notification-message) { + color: var(--black); + text-decoration-line: underline; + } + + a.system-notification-message { + color: var(--text-color); + text-decoration: unset; + } +} + +.system-notification-exception { + background-color: var(--red-40); + + .system-notification-timeout { + background-color: var(--red); + } +} + +.system-notification-error { + background-color: var(--red-20); + + .system-notification-timeout { + background-color: var(--red-80); + } +} + +.system-notification-warning { + background-color: var(--yellow-20); + + .system-notification-timeout { + background-color: var(--yellow-80); + } +} + +.system-notification-success { + background-color: var(--green-20); + + .system-notification-timeout { + background-color: var(--green-80); + } +} + +.system-notification-info { + background-color: var(--dark-violet-20); + + .system-notification-timeout { + background-color: var(--dark-violet-60); + } +} diff --git a/resources/assets/stylesheets/scss/tables.scss b/resources/assets/stylesheets/scss/tables.scss index 9e05f01..665a6f3 100644 --- a/resources/assets/stylesheets/scss/tables.scss +++ b/resources/assets/stylesheets/scss/tables.scss @@ -504,7 +504,7 @@ table.default { > thead { > tr > th { background-color: var(--content-color-20); - border-bottom: 1px solid fadeout($brand-color-lighter, 80%); + border-bottom: 1px solid fade-out($brand-color-lighter, 0.8); border-top: 1px solid var(--brand-color-darker); font-size: 1.0em; } @@ -515,7 +515,7 @@ table.default { > th { background-color: var(--content-color-20); border-top: 1px solid var(--brand-color-darker); - border-bottom: 1px solid fadeout($brand-color-lighter, 80%); + border-bottom: 1px solid fade-out($brand-color-lighter, 0.8); text-align: left; } @@ -580,7 +580,7 @@ table.default { // Hover effect &:not(.nohover) > tbody:not(.nohover) > tr:not(.nohover):hover > td:not(.nohover) { - background-color: fadeout($light-gray-color, 80%); + background-color: fade-out($light-gray-color, 0.8); } &:not(.nohover) > tbody:not(.nohover) > tr.selected:not(.nohover):hover > td:not(.nohover) { @@ -742,7 +742,7 @@ table.withdetails { } > tbody > tr.open > td { - background-color: fadeout($light-gray-color, 80%); + background-color: fade-out($light-gray-color, 0.8); } > tbody > tr.open > td:first-child { diff --git a/resources/assets/stylesheets/scss/tabs.scss b/resources/assets/stylesheets/scss/tabs.scss index b37005e..63bf1a0 100644 --- a/resources/assets/stylesheets/scss/tabs.scss +++ b/resources/assets/stylesheets/scss/tabs.scss @@ -31,8 +31,6 @@ div.clear .quiet img { opacity: 0.25; } li { - background-color: var(--dark-gray-color-10); - &:last-child { border-right: none; } diff --git a/resources/assets/stylesheets/scss/talk-bubble.scss b/resources/assets/stylesheets/scss/talk-bubble.scss index bc44600..380fb6d 100644 --- a/resources/assets/stylesheets/scss/talk-bubble.scss +++ b/resources/assets/stylesheets/scss/talk-bubble.scss @@ -8,6 +8,13 @@ $ownColor: var(--petrol-40); .talk-bubble-avatar { padding: 8px; + width: 40px; + height: 40px; + + img { + width: 100%; + height: 100%; + } } .talk-bubble { @@ -136,4 +143,4 @@ $ownColor: var(--petrol-40); flex-direction: row-reverse; } } -}
\ No newline at end of file +} diff --git a/resources/assets/stylesheets/scss/tooltip.scss b/resources/assets/stylesheets/scss/tooltip.scss index 4b7b36b..ac570a8 100644 --- a/resources/assets/stylesheets/scss/tooltip.scss +++ b/resources/assets/stylesheets/scss/tooltip.scss @@ -7,7 +7,7 @@ box-shadow: 0 1px 0 fade-out($white, 0.5) inset; font-size: var(--font-size-base); margin-bottom: 8px; - max-width: 230px; + max-width: $grid-element-width; padding: 10px; position: absolute; text-align: left; @@ -38,11 +38,13 @@ @extend %tooltip; display: none; } - &:hover .tooltip-content { + + &:hover .tooltip-content, + &:focus .tooltip-content { bottom: 100%; display: inline-block; left: 50%; - margin-left: -129px; - width: 230px; + margin-left: - calc($grid-element-width / 2) - 10px; + width: $grid-element-width; } } diff --git a/resources/assets/stylesheets/scss/wiki.scss b/resources/assets/stylesheets/scss/wiki.scss index e19b9b1..323fbb5 100644 --- a/resources/assets/stylesheets/scss/wiki.scss +++ b/resources/assets/stylesheets/scss/wiki.scss @@ -190,12 +190,12 @@ article.studip.wiki { padding: 0; li { margin-bottom: 5px; + display: flex; } a { background-position: left top; background-repeat: no-repeat; background-size: var(--avatar-small); - display: block; min-height: var(--avatar-small); padding-left: calc(var(--avatar-small) + 1ex); } diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index d19621a..bf4022a 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -32,7 +32,6 @@ @import "scss/contents"; @import "scss/content"; @import "scss/comments"; -@import "scss/copyable-links"; @import "scss/cronjobs"; @import "scss/coursewizard"; @import "scss/css_tree"; @@ -42,7 +41,6 @@ @import "scss/documents"; @import "scss/drag-handle"; @import "scss/enrolment"; -@import "scss/evaluation"; @import "scss/files"; @import "scss/feedback"; @import "scss/forms"; @@ -97,6 +95,7 @@ @import "scss/studygroup"; @import "scss/studip-overlay"; @import "scss/studip-selection"; +@import "scss/system-notifications"; @import "scss/table_of_contents"; @import "scss/tables"; @import "scss/tabs"; @@ -244,8 +243,6 @@ ol.clean { padding: 5px; } - - .minor { color: var(--black); font-size: 0.75em; diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js index 2390bb9..5d11daa 100644 --- a/resources/vue/base-components.js +++ b/resources/vue/base-components.js @@ -1,63 +1,33 @@ -import CalendarPermissionsTable from "./components/form_inputs/CalendarPermissionsTable.vue"; -import DayOfWeekSelect from './components/form_inputs/DayOfWeekSelect.vue'; -import DateListInput from './components/form_inputs/DateListInput.vue'; -import Multiselect from './components/Multiselect.vue'; -import MyCoursesColouredTable from './components/form_inputs/MyCoursesColouredTable.vue'; -import EditableList from "./components/EditableList.vue"; -import Quicksearch from './components/Quicksearch.vue'; -import RepetitionInput from "./components/form_inputs/RepetitionInput.vue"; -import SidebarWidget from './components/SidebarWidget.vue'; -import StudipActionMenu from './components/StudipActionMenu.vue'; -import StudipAssetImg from './components/StudipAssetImg.vue'; -import StudipDateTime from './components/StudipDateTime.vue'; -import StudipDialog from './components/StudipDialog.vue'; -import StudipFileSize from './components/StudipFileSize.vue'; -import StudipFolderSize from './components/StudipFolderSize.vue'; -import StudipIcon from './components/StudipIcon.vue'; -import RangeInput from './components/RangeInput.vue'; -import Datepicker from './components/Datepicker.vue'; -import Datetimepicker from './components/Datetimepicker.vue'; -import TextareaWithToolbar from './components/TextareaWithToolbar.vue'; -import I18nTextarea from "./components/I18nTextarea.vue"; -import StudipWysiwyg from "./components/StudipWysiwyg.vue"; -// import StudipLoadingIndicator from './StudipLoadingIndicator.vue'; -import StudipMessageBox from './components/StudipMessageBox.vue'; -import StudipProxyCheckbox from './components/StudipProxyCheckbox.vue'; -import StudipProxiedCheckbox from './components/StudipProxiedCheckbox.vue'; -import StudipTooltipIcon from './components/StudipTooltipIcon.vue'; -import StudipSelect from './components/StudipSelect.vue'; -import StudipMultiPersonSearch from './components/StudipMultiPersonSearch.vue'; - const BaseComponents = { - CalendarPermissionsTable, - DayOfWeekSelect, - DateListInput, - Multiselect, - MyCoursesColouredTable, - EditableList, - Quicksearch, - RangeInput, - RepetitionInput, - SidebarWidget, - StudipActionMenu, - StudipAssetImg, - StudipDateTime, - Datepicker, - Datetimepicker, - StudipDialog, - StudipFileSize, - StudipFolderSize, - StudipIcon, - I18nTextarea, - StudipWysiwyg, -// StudipLoadingIndicator, - StudipMessageBox, - StudipProxyCheckbox, - StudipProxiedCheckbox, - StudipTooltipIcon, - StudipSelect, - TextareaWithToolbar, - StudipMultiPersonSearch + CaptchaInput: () => import('./components/form_inputs/CaptchaInput.vue'), + CalendarPermissionsTable: () => import("./components/form_inputs/CalendarPermissionsTable.vue"), + DateListInput: () => import('./components/form_inputs/DateListInput.vue'), + Datepicker: () => import('./components/Datepicker.vue'), + Datetimepicker: () => import('./components/Datetimepicker.vue'), + DayOfWeekSelect: () => import('./components/form_inputs/DayOfWeekSelect.vue'), + EditableList: () => import("./components/EditableList.vue"), + I18nTextarea: () => import("./components/I18nTextarea.vue"), + Multiselect: () => import('./components/Multiselect.vue'), + MyCoursesColouredTable: () => import('./components/form_inputs/MyCoursesColouredTable.vue'), + Quicksearch: () => import('./components/Quicksearch.vue'), + RangeInput: () => import('./components/RangeInput.vue'), + RepetitionInput: () => import("./components/form_inputs/RepetitionInput.vue"), + SidebarWidget: () => import('./components/SidebarWidget.vue'), + StudipActionMenu: () => import('./components/StudipActionMenu.vue'), + StudipAssetImg: () => import('./components/StudipAssetImg.vue'), + StudipDateTime: () => import('./components/StudipDateTime.vue'), + StudipDialog: () => import('./components/StudipDialog.vue'), + StudipFileSize: () => import('./components/StudipFileSize.vue'), + StudipFolderSize: () => import('./components/StudipFolderSize.vue'), + StudipIcon: () => import('./components/StudipIcon.vue'), + StudipMessageBox: () => import('./components/StudipMessageBox.vue'), + StudipMultiPersonSearch: () => import('./components/StudipMultiPersonSearch.vue'), + StudipProxiedCheckbox: () => import('./components/StudipProxiedCheckbox.vue'), + StudipProxyCheckbox: () => import('./components/StudipProxyCheckbox.vue'), + StudipSelect: () => import('./components/StudipSelect.vue'), + StudipTooltipIcon: () => import('./components/StudipTooltipIcon.vue'), + StudipWysiwyg: () => import("./components/StudipWysiwyg.vue"), + TextareaWithToolbar: () => import('./components/TextareaWithToolbar.vue'), }; export default BaseComponents; 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> diff --git a/resources/vue/mixins/QuestionnaireComponent.js b/resources/vue/mixins/QuestionnaireComponent.js new file mode 100644 index 0000000..277f21c --- /dev/null +++ b/resources/vue/mixins/QuestionnaireComponent.js @@ -0,0 +1,24 @@ +export const QuestionnaireComponent = { + props: { + value: Object + }, + data () { + return {val_clone: this.value}; + }, + methods: { + setDefaultValues(value) { + this.val_clone = Object.assign(value, this.value); + } + }, + watch: { + val_clone: { + handler(current) { + this.$emit('input', current); + }, + deep: true + }, + value (new_val) { + this.val_clone = new_val; + } + } +}; diff --git a/resources/vue/mixins/courseware/import.js b/resources/vue/mixins/courseware/import.js index a7c9420..547280f 100644 --- a/resources/vue/mixins/courseware/import.js +++ b/resources/vue/mixins/courseware/import.js @@ -53,7 +53,6 @@ export default { this.elementCounter = await this.countImportElements([element]); this.setImportStructuresState(''); this.importElementCounter = 0; - this.setImportErrors([]); if (importBehavior === 'default') { await this.importStructuralElement([element], rootId, files); @@ -286,8 +285,14 @@ export default { let new_file = this.file_mapping[files[i].id].new; let payload = JSON.stringify(block.attributes.payload); - payload = payload.replaceAll(old_file.id, new_file.id); - payload = payload.replaceAll(old_file.folder.id, new_file.relationships.parent.data.id); + if (new_file) { + payload = payload.replaceAll(old_file.id, new_file.id); + payload = payload.replaceAll(old_file.folder.id, new_file.relationships.parent.data.id); + } else { + payload = payload.replaceAll(old_file.id, ''); + payload = payload.replaceAll(old_file.folder.id, ''); + } + block.attributes.payload = JSON.parse(payload); } @@ -395,13 +400,20 @@ export default { // create new blob with correct type let filedata = zip_filedata.slice(0, zip_filedata.size, files[i].attributes['mime-type']); - - let file = await this.createFile({ - file: files[i], - filedata: filedata, - folder: folders[files[i].folder.id] - }); - this.setImportFilesState(this.$gettext('Erzeuge Datei') + ': ' + files[i].attributes.name); + let file = null; + try { + file = await this.createFile({ + file: files[i], + filedata: filedata, + folder: folders[files[i].folder.id] + }); + } catch (error) { + this.currentImportErrors.push(this.$gettext('Import einer Datei fehlgeschlagen.')); + this.setImportFilesState(this.$gettext('Fehler beim Anlegen der Datei')); + } + if (file !== null) { + this.setImportFilesState(this.$gettext('Erzeuge Datei') + ': ' + files[i].attributes.name); + } this.setImportFilesProgress(parseInt(i / files.length * 100)); //file mapping diff --git a/resources/vue/store/ContentModulesStore.js b/resources/vue/store/ContentModulesStore.js index 9dc4609..141a178 100644 --- a/resources/vue/store/ContentModulesStore.js +++ b/resources/vue/store/ContentModulesStore.js @@ -52,7 +52,7 @@ export default { attributes: { value: view === 'tiles' } }; - return STUDIP.jsonapi.PATCH(`config-values/${documentId}`, { data: { data } }) ; + return STUDIP.jsonapi.withPromises().patch(`config-values/${documentId}`, { data: { data } }) ; }, exchangeModules({ commit, state }, modules) { const order = modules.filter(module => module.active) diff --git a/resources/vue/store/MyCoursesStore.js b/resources/vue/store/MyCoursesStore.js index 08c0389..af8e0fc 100644 --- a/resources/vue/store/MyCoursesStore.js +++ b/resources/vue/store/MyCoursesStore.js @@ -71,7 +71,7 @@ export default { attributes: { value: configValue[configKey] } }; - return STUDIP.jsonapi.PATCH(`config-values/${documentId}`, { data: { data } }) + return STUDIP.jsonapi.withPromises().patch(`config-values/${documentId}`, { data: { data } }) }, toggleOpenGroup ({ state, dispatch }, group) { let open_groups = [ ...state.config.open_groups ]; diff --git a/resources/vue/store/blubber.js b/resources/vue/store/blubber.js index 9cc474a..d0bd7f6 100644 --- a/resources/vue/store/blubber.js +++ b/resources/vue/store/blubber.js @@ -166,7 +166,7 @@ export default { // if total is missing, there are more comments to fetch const total = rootGetters['blubber-comments/lastMeta']?.page?.total; - const hasMore = !total; + const hasMore = total ?? true; commit('setMoreOlder', { id, hasMore }); }, diff --git a/resources/vue/store/courseware/courseware-shelf.module.js b/resources/vue/store/courseware/courseware-shelf.module.js index dc92a23..d907ee4 100644 --- a/resources/vue/store/courseware/courseware-shelf.module.js +++ b/resources/vue/store/courseware/courseware-shelf.module.js @@ -249,33 +249,23 @@ export const actions = { } }, async companionInfo({ dispatch }, { info }) { - await dispatch('setStyleCompanionOverlay', 'default'); - await dispatch('setMsgCompanionOverlay', info); - return dispatch('setShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'info', message: info}); }, async companionSuccess({ dispatch }, { info }) { - await dispatch('setStyleCompanionOverlay', 'happy'); - await dispatch('setMsgCompanionOverlay', info); - return dispatch('setShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'success', message: info}); }, async companionError({ dispatch }, { info }) { - await dispatch('setStyleCompanionOverlay', 'sad'); - await dispatch('setMsgCompanionOverlay', info); - return dispatch('setShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'error', message: info}); }, async companionWarning({ dispatch }, { info }) { - await dispatch('setStyleCompanionOverlay', 'alert'); - await dispatch('setMsgCompanionOverlay', info); - return dispatch('setShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'warning', message: info}); }, async companionSpecial({ dispatch }, { info }) { - await dispatch('setStyleCompanionOverlay', 'special'); - await dispatch('setMsgCompanionOverlay', info); - return dispatch('setShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'info', message: info}); }, coursewareShowCompanionOverlay({dispatch}, { data }) { return dispatch('setShowCompanionOverlay', data); @@ -310,7 +300,7 @@ export const actions = { return dispatch(loadUnits, state.context.id); }, - + async sortUnits({ dispatch, state }, data) { let loadUnits = null; if (state.context.type === 'courses') { @@ -321,7 +311,7 @@ export const actions = { } await state.httpClient.post(`${state.context.type}/${state.context.id}/courseware-units/sort`, {data: data}); - + return dispatch(loadUnits, state.context.id); }, diff --git a/resources/vue/store/courseware/courseware.module.js b/resources/vue/store/courseware/courseware.module.js index c44bba1..784b750 100644 --- a/resources/vue/store/courseware/courseware.module.js +++ b/resources/vue/store/courseware/courseware.module.js @@ -874,33 +874,23 @@ export const actions = { }, async companionInfo({ dispatch }, { info }) { - await dispatch('coursewareStyleCompanionOverlay', 'default'); - await dispatch('coursewareMsgCompanionOverlay', info); - return dispatch('coursewareShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'info', message: info}); }, async companionSuccess({ dispatch }, { info }) { - await dispatch('coursewareStyleCompanionOverlay', 'happy'); - await dispatch('coursewareMsgCompanionOverlay', info); - return dispatch('coursewareShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'success', message: info}); }, async companionError({ dispatch }, { info }) { - await dispatch('coursewareStyleCompanionOverlay', 'sad'); - await dispatch('coursewareMsgCompanionOverlay', info); - return dispatch('coursewareShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'error', message: info}); }, async companionWarning({ dispatch }, { info }) { - await dispatch('coursewareStyleCompanionOverlay', 'alert'); - await dispatch('coursewareMsgCompanionOverlay', info); - return dispatch('coursewareShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'exception', message: info}); }, async companionSpecial({ dispatch }, { info }) { - await dispatch('coursewareStyleCompanionOverlay', 'special'); - await dispatch('coursewareMsgCompanionOverlay', info); - return dispatch('coursewareShowCompanionOverlay', true); + STUDIP.eventBus.emit('push-system-notification', { type: 'warning', message: info}); }, // adds a favorite block type using the `type` of the BlockType |
