From 74603117e50e764dfb0233d49cb99ffafaccac54 Mon Sep 17 00:00:00 2001
From: Rasmus Fuhse <fuhse@data-quest.de>
Date: Thu, 13 Jul 2023 13:14:56 +0000
Subject: [PATCH] Resolve "Restrukturierung der Veranstaltungsverwaltung
 inklusive Mehr-Seite"

Closes #2440

Merge request studip/studip!1695
---
 app/controllers/admin/plugin.php              |  45 ++
 app/controllers/course/basicdata.php          |  58 ++-
 app/controllers/course/change_view.php        |   2 +-
 app/controllers/course/contentmodules.php     | 293 +++++++++++++
 app/controllers/course/plus.php               | 399 ------------------
 app/views/admin/plugin/edit_description.php   |   1 +
 app/views/admin/plugin/index.php              |  10 +
 app/views/course/contentmodules/index.php     |   1 +
 app/views/course/contentmodules/info.php      |  56 +++
 app/views/course/contentmodules/rename.php    |  24 ++
 app/views/course/plus/edittool.php            |  25 --
 app/views/course/plus/index.php               | 245 -----------
 app/views/course/plus/sorttools.php           |  15 -
 .../5.4.10_contentmodules_description.php     |  47 +++
 .../ConfigValues/ConfigValuesUpdate.php       |   1 +
 lib/classes/JsonApi/Schemas/Course.php        |  22 +
 lib/classes/SemClass.class.php                |   8 +-
 lib/classes/forms/DatetimepickerInput.php     |   9 +
 lib/classes/forms/Form.php                    |  26 +-
 lib/classes/forms/InfoInput.php               |  20 +
 lib/classes/forms/Part.php                    |  16 +-
 lib/classes/forms/WysiwygInput.php            |  30 ++
 lib/classes/sidebar/OptionsWidget.php         |   3 +-
 lib/models/Plugin.php                         |  25 ++
 lib/models/ToolActivation.php                 |   2 +-
 lib/modules/Blubber.class.php                 |   4 +-
 lib/modules/ConsultationModule.class.php      |   1 +
 lib/modules/CoreAdmin.class.php               |   6 +-
 lib/modules/CoreCalendar.class.php            |   1 +
 lib/modules/CoreDocuments.class.php           |   3 +-
 lib/modules/CoreElearningInterface.class.php  |   1 +
 lib/modules/CoreForum.class.php               |   1 +
 lib/modules/CoreOverview.class.php            |   5 +-
 lib/modules/CoreParticipants.class.php        |   1 +
 lib/modules/CorePersonal.class.php            |   1 +
 lib/modules/CoreSchedule.class.php            |   1 +
 lib/modules/CoreScm.class.php                 |   1 +
 lib/modules/CoreStudygroupAdmin.class.php     |   1 +
 lib/modules/CoreWiki.class.php                |   3 +-
 lib/modules/CoursewareModule.class.php        |   7 +-
 lib/modules/FeedbackModule.class.php          |   1 +
 lib/modules/GradebookModule.class.php         |   1 +
 lib/modules/IliasInterfaceModule.class.php    |   1 +
 lib/modules/LtiToolModule.class.php           |   1 +
 lib/navigation/CourseNavigation.php           |  21 +-
 lib/plugins/core/CorePlugin.php               |  35 ++
 lib/plugins/core/StudIPPlugin.class.php       |  39 ++
 lib/plugins/engine/PluginManager.class.php    |  11 +-
 lib/seminar_open.php                          |  13 -
 .../javascripts/bootstrap/contentmodules.js   |  40 ++
 .../assets/javascripts/bootstrap/forms.js     |  13 +-
 resources/assets/javascripts/entry-base.js    |   1 +
 resources/assets/javascripts/init.js          |   2 -
 resources/assets/javascripts/lib/plus.js      |  23 -
 resources/assets/stylesheets/scss/forms.scss  |   3 +
 resources/assets/stylesheets/scss/plus.scss   |  79 ----
 resources/assets/stylesheets/studip.scss      |   1 -
 resources/vue/base-components.js              |   2 +
 resources/vue/components/ContentModules.vue   | 218 ++++++++++
 .../vue/components/ContentModulesControl.vue  |  91 ++++
 .../components/ContentModulesEditTiles.vue    | 165 ++++++++
 .../components/ContentmodulesEditTable.vue    | 100 +++++
 resources/vue/components/Datetimepicker.vue   |   8 +-
 resources/vue/components/I18nTextarea.vue     |   2 +
 resources/vue/mixins/ContentModulesMixin.js   | 125 ++++++
 resources/vue/store/ContentModulesStore.js    | 124 ++++++
 templates/forms/form.php                      |   3 +-
 templates/forms/i18n_formatted_input.php      |   1 +
 templates/forms/i18n_textarea_input.php       |   1 +
 templates/forms/info_input.php                |   8 +
 templates/forms/wysiwyg_input.php             |  16 +
 71 files changed, 1725 insertions(+), 844 deletions(-)
 create mode 100644 app/controllers/course/contentmodules.php
 delete mode 100644 app/controllers/course/plus.php
 create mode 100644 app/views/admin/plugin/edit_description.php
 create mode 100644 app/views/course/contentmodules/index.php
 create mode 100644 app/views/course/contentmodules/info.php
 create mode 100644 app/views/course/contentmodules/rename.php
 delete mode 100644 app/views/course/plus/edittool.php
 delete mode 100644 app/views/course/plus/index.php
 delete mode 100644 app/views/course/plus/sorttools.php
 create mode 100644 db/migrations/5.4.10_contentmodules_description.php
 create mode 100644 lib/classes/forms/InfoInput.php
 create mode 100644 lib/classes/forms/WysiwygInput.php
 create mode 100644 lib/models/Plugin.php
 create mode 100644 resources/assets/javascripts/bootstrap/contentmodules.js
 delete mode 100644 resources/assets/javascripts/lib/plus.js
 delete mode 100644 resources/assets/stylesheets/scss/plus.scss
 create mode 100644 resources/vue/components/ContentModules.vue
 create mode 100644 resources/vue/components/ContentModulesControl.vue
 create mode 100644 resources/vue/components/ContentModulesEditTiles.vue
 create mode 100644 resources/vue/components/ContentmodulesEditTable.vue
 create mode 100644 resources/vue/mixins/ContentModulesMixin.js
 create mode 100644 resources/vue/store/ContentModulesStore.js
 create mode 100644 templates/forms/info_input.php
 create mode 100644 templates/forms/wysiwyg_input.php

diff --git a/app/controllers/admin/plugin.php b/app/controllers/admin/plugin.php
index 7a87d8fe275..ef635175640 100644
--- a/app/controllers/admin/plugin.php
+++ b/app/controllers/admin/plugin.php
@@ -571,4 +571,49 @@ class Admin_PluginController extends AuthenticatedController
         }
     }
 
+    public function edit_description_action(Plugin $plugin)
+    {
+        $this->plugin = PluginManager::getInstance()->getPluginById($plugin->getId());
+        $this->metadata = $this->plugin->getMetadata();
+        $this->form = \Studip\Forms\Form::fromSORM($plugin, [
+            'legend' => _('Pluginbeschreibung'),
+            'fields' => [
+                'description' => [
+                    'label' => _('Beschreibung'),
+                    'type' => 'i18n_formatted'
+                ],
+                'manifest_info_de' => [
+                    'label' => _('Standardbeschreibung des Plugins'),
+                    'type' => 'info',
+                    'value' => $this->metadata['descriptionlong'] ?? $this->metadata['description'],
+                    'if' => "STUDIPFORM_SELECTEDLANGUAGES.description === 'de_DE'"
+                ],
+                'manifest_info_en' => [
+                    'label' => sprintf(_('Standardbeschreibung des Plugins (%s)'), _('Englisch')),
+                    'type' => 'info',
+                    'value' => $this->metadata['descriptionlong_en'] ?? $this->metadata['description_en'],
+                    'if' => "STUDIPFORM_SELECTEDLANGUAGES.description === 'en_GB'"
+                ],
+                'decription_mode' => [
+                    'label' => _('Modus der neuen Beschreibung'),
+                    'type' => 'select',
+                    'options' => [
+                        'add' => _('Hinzufügen zur Standardbeschreibung'),
+                        'override_description' => _('Standardbeschreibung überschreiben'),
+                        'replace_all' => _('Beschreibungsfenster komplett ersetzen durch Beschreibung')
+                    ]
+                ],
+                'highlight_until' => [
+                    'label' => _('In Veranstaltungen bewerben bis (oder leer lassen)'),
+                    'type' => 'datetimepicker'
+                ],
+                'highlight_text' => [
+                    'label' => _('Bewerbungs-Infotext')
+                ]
+            ]
+        ])->autoStore()
+          //->setDebugMode(true)
+          ->setURL(URLHelper::getURL('dispatch.php/admin/plugin/index'));
+    }
+
 }
diff --git a/app/controllers/course/basicdata.php b/app/controllers/course/basicdata.php
index b9ba67e793b..937ec0f0d02 100644
--- a/app/controllers/course/basicdata.php
+++ b/app/controllers/course/basicdata.php
@@ -290,7 +290,8 @@ class Course_BasicdataController extends AuthenticatedController
         }
 
         //Daten sammeln:
-        $sem = Seminar::getInstance($this->course_id);
+        $course = Course::find($this->course_id);
+        $sem = new Seminar($course);
         $data = $sem->getData();
 
         //Erster, zweiter und vierter Reiter des Akkordions: Grundeinstellungen
@@ -365,10 +366,51 @@ class Course_BasicdataController extends AuthenticatedController
 
         $widget = new ActionsWidget();
 
+        $sem_create_perm = in_array(Config::get()->SEM_CREATE_PERM, ['root','admin','dozent']) ? Config::get()->SEM_CREATE_PERM : 'dozent';
+        if ($GLOBALS['perm']->have_perm($sem_create_perm)) {
+            if (!LockRules::check(Context::getId(), 'seminar_copy')) {
+                $widget->addLink(
+                    _('Veranstaltung kopieren'),
+                    $this->url_for(
+                         'course/wizard/copy/' . $this->course_id,
+                         ['studip_ticket' => Seminar_Session::get_ticket()]
+                    ),
+                    Icon::create('seminar')
+                );
+            }
+        }
         $widget->addLink(_('Bild ändern'),
-             $this->url_for('avatar/update/course', $course_id),
-             Icon::create('edit')
+            $this->url_for('avatar/update/course', $this->course_id),
+            Icon::create('edit')
         );
+        if ($GLOBALS['perm']->have_perm('admin')) {
+            $is_locked = $course->lock_rule;
+            $widget->addLink(
+                _('Sperrebene ändern') . ' (' . ($is_locked ? _('gesperrt') : _('nicht gesperrt')) . ')',
+                $this->url_for(
+                    'course/management/lock',
+                    ['studip_ticket' => Seminar_Session::get_ticket()]
+                ),
+                Icon::create('lock-' . ($is_locked ? 'locked' : 'unlocked'))
+            )->asDialog('size=auto');
+        }
+
+        if (
+            (Config::get()->ALLOW_DOZENT_VISIBILITY || $GLOBALS['perm']->have_perm('admin'))
+            && !LockRules::Check($this->course_id, 'seminar_visibility')
+        ) {
+            $is_visible = $course->visible;
+            if ($course->isOpenEnded() || $course->end_semester->visible) {
+                $widget->addLink(
+                    $is_visible ? _('Veranstaltung verstecken') : _('Veranstaltung sichtbar schalten'),
+                    $this->url_for(
+                        'course/management/change_visibility',
+                        ['studip_ticket' => Seminar_Session::get_ticket()]
+                    ),
+                    Icon::create('visibility-' . ($is_visible ? 'visible' : 'invisible'))
+                );
+            }
+        }
 
         if ($this->deputies_enabled) {
             if (Deputy::isDeputy($GLOBALS['user']->id, $this->course_id)) {
@@ -388,6 +430,16 @@ class Course_BasicdataController extends AuthenticatedController
                 );
             }
         }
+        if (Config::get()->ALLOW_DOZENT_DELETE || $GLOBALS['perm']->have_perm('admin')) {
+            $widget->addLink(
+                _('Veranstaltung löschen'),
+                $this->url_for(
+                    'course/archive/confirm',
+                    ['studip_ticket' => Seminar_Session::get_ticket()]
+                ),
+                Icon::create('trash')
+            )->asDialog('size=auto');
+        }
         $sidebar->addWidget($widget);
         if ($GLOBALS['perm']->have_studip_perm('admin', $this->course_id)) {
             $widget = new CourseManagementSelectWidget();
diff --git a/app/controllers/course/change_view.php b/app/controllers/course/change_view.php
index 58cc9957880..156a68a8fc7 100644
--- a/app/controllers/course/change_view.php
+++ b/app/controllers/course/change_view.php
@@ -48,6 +48,6 @@ class Course_ChangeViewController extends AuthenticatedController
     public function reset_changed_view_action()
     {
         unset($_SESSION["seminar_change_view_{$this->course_id}"]);
-        $this->relocate('course/management');
+        $this->relocate('course/contentmodules');
     }
 }
diff --git a/app/controllers/course/contentmodules.php b/app/controllers/course/contentmodules.php
new file mode 100644
index 00000000000..2e4907a7e25
--- /dev/null
+++ b/app/controllers/course/contentmodules.php
@@ -0,0 +1,293 @@
+<?php
+
+class Course_ContentmodulesController extends AuthenticatedController
+{
+    public function index_action()
+    {
+        Navigation::activateItem('/course/admin/contentmodules');
+        PageLayout::setTitle(_('Werkzeuge'));
+
+        if (Context::isCourse()) {
+            $this->sem = Context::get();
+            $this->sem_class = $this->sem->getSemClass();
+        } else {
+            $this->sem = Context::get();
+            $this->sem_class = SemClass::getDefaultInstituteClass($this->sem['type']);
+        }
+        $this->modules = $this->getModules($this->sem);
+
+        $this->highlighted_modules = [];
+        foreach ($this->modules as $module) {
+            if ($module['highlighted']) {
+                $this->highlighted_modules[] = $module['id'];
+            }
+        }
+
+        if (Context::isCourse()) {
+            $actions = new ActionsWidget();
+
+            $actions->addLink(
+                _('Studierendenansicht simulieren'),
+                URLHelper::getURL('dispatch.php/course/change_view/set_changed_view'),
+                Icon::create('visibility-invisible')
+            );
+            Sidebar::Get()->addWidget($actions);
+        }
+
+        $views = Sidebar::Get()->addWidget(new ViewsWidget());
+        $views->id = 'tool-view-switch';
+        $views->addLink(
+            _('Kachelansicht'),
+            '#tiles'
+        )->setActive($GLOBALS['user']->cfg->CONTENTMODULES_TILED_DISPLAY);
+        $views->addLink(
+            _('Tabellarische Ansicht'),
+            '#tabular'
+        )->setActive(!$GLOBALS['user']->cfg->CONTENTMODULES_TILED_DISPLAY);
+
+        $this->categories = [];
+        foreach ($this->modules as $i => $module) {
+            if ($module['category'] && !in_array($module['category'], $this->categories)) {
+                $this->categories[] = $module['category'];
+            }
+            if (!$module['category']) {
+                if (!in_array(_('Sonstige'), $this->categories)) {
+                    $this->categories[] = _('Sonstige');
+                }
+                $this->modules[$i]['category'] = _('Sonstige');
+            }
+        }
+        sort($this->categories);
+
+        $filter_widget = Sidebar::Get()->addWidget(new OptionsWidget());
+        $filter_widget->id = 'tool-filter-category';
+        $filter_widget->setTitle(_('Filter nach Kategorie'));
+        $filter_widget->addRadioButton(
+            _('Alle Kategorien'),
+            '#',
+            true
+        );
+        foreach ($this->categories as $category) {
+            $filter_widget->addRadioButton(
+                $category,
+                '#'
+            );
+        }
+
+        if (
+            Context::isCourse()
+            && $GLOBALS['perm']->have_studip_perm('admin', Context::getId())
+            && !$this->sem_class['studygroup_mode']
+        ) {
+            $widget = new CourseManagementSelectWidget();
+            Sidebar::Get()->addWidget($widget);
+        }
+
+        PageLayout::addHeadElement('script', [
+            'type' => 'text/javascript',
+        ], sprintf(
+            'window.ContentModulesStoreData = %s;',
+            json_encode([
+                'setCategories' => $this->categories,
+                'setHighlighted' => $this->highlighted_modules,
+                'setModules' => array_values($this->modules),
+                'setUserId' => User::findCurrent()->id,
+                'setView' => $GLOBALS['user']->cfg->CONTENTMODULES_TILED_DISPLAY ? 'tiles' : 'table',
+            ])
+        ));
+    }
+
+    public function trigger_action()
+    {
+        $context = Context::get();
+
+        $required_perm = $context->getRangeType() === 'course' ? 'tutor' : 'admin';
+        if (!$GLOBALS['perm']->have_studip_perm($required_perm, $context->id)) {
+            throw new AccessDeniedException();
+        }
+        if (Request::isPost()) {
+            if ($context->getRangeType() === 'course') {
+                $sem_class = $context->getSemClass();
+            } else {
+                $sem_class = SemClass::getDefaultInstituteClass($context->type);
+            }
+            $moduleclass = Request::get('moduleclass');
+            $active = Request::bool('active', false);
+            $module = new $moduleclass;
+            if ($module->isActivatableForContext($context)) {
+                PluginManager::getInstance()->setPluginActivated($module->getPluginId(), $context->getId(), $active);
+            }
+            if ($active) {
+                $active_tool = ToolActivation::find([$context->id, $module->getPluginId()]);
+                $default_position = array_search(get_class($module), $sem_class->getActivatedModules());
+                if ($default_position !== false && $active_tool) {
+                    $active_tool->position = $default_position;
+                    $active_tool->store();
+                }
+            }
+            //$this->redirect("course/contentmodules/trigger", ['cid' => $context->getId()]);
+        }
+        $template = $GLOBALS['template_factory']->open('tabs.php');
+        $template->navigation = Navigation::getItem('/course');
+        Navigation::getItem('/course/admin')->setActive(true);
+        $this->render_json([
+            'tabs' => $template->render(),
+            'position' => $active_tool->position
+        ]);
+    }
+
+    public function reorder_action()
+    {
+        $context = Context::get();
+
+        $required_perm = $context->getRangeType() === 'course' ? 'tutor' : 'admin';
+        if (!$GLOBALS['perm']->have_studip_perm($required_perm, $context->id)) {
+            throw new AccessDeniedException();
+        }
+        if (Request::isPost()) {
+            $position = 0;
+            foreach (Request::getArray('order') as $plugin_id) {
+                $tool = ToolActivation::find([$context->getId(), $plugin_id]);
+                $tool->position = $position++;
+                $tool->store();
+            }
+            $this->redirect($this->reorderURL());
+            return;
+        }
+        Navigation::getItem('/course/admin')->setActive(true);
+        $template = $GLOBALS['template_factory']->open('tabs.php');
+        $template->navigation = Navigation::getItem('/course');
+        $this->render_json([
+            'tabs' => $template->render()
+        ]);
+    }
+
+    public function change_visibility_action()
+    {
+        if (!Request::isPost()) {
+            throw new AccessDeniedException();
+        }
+        $context = Context::get();
+
+        $required_perm = $context->getRangeType() === 'course' ? 'tutor' : 'admin';
+        if (!$GLOBALS['perm']->have_studip_perm($required_perm, $context->id)) {
+            throw new AccessDeniedException();
+        }
+        $moduleclass = Request::get('moduleclass');
+        $module = new $moduleclass;
+
+        $active_tool = ToolActivation::find([$context->id, $module->getPluginId()]);
+        $metadata = $active_tool->metadata->getArrayCopy();
+        if (Request::bool('visible')) {
+            unset($metadata['visibility']);
+        } else {
+            $metadata['visibility'] = 'tutor';
+        }
+        $active_tool['metadata'] = $metadata;
+        $active_tool->store();
+
+        $this->render_json([
+            'visibility' => $active_tool->getVisibilityPermission()
+        ]);
+    }
+
+    public function tiles_display_action()
+    {
+        if (Request::isPost()) {
+            $GLOBALS['user']->cfg->store(
+                'CONTENTMODULES_TILED_DISPLAY',
+                Request::get('view') === 'tiles'
+            );
+        }
+        $this->render_nothing();
+    }
+
+    public function rename_action($module_id)
+    {
+        $context = Context::get();
+
+        $required_perm = $context->getRangeType() === 'course' ? 'tutor' : 'admin';
+        if (!$GLOBALS['perm']->have_studip_perm($required_perm, $context->id)) {
+            throw new AccessDeniedException();
+        }
+        $this->module = PluginManager::getInstance()->getPluginById($module_id);
+        $this->metadata = $this->module->getMetadata();
+        PageLayout::setTitle(_('Werkzeug umbenennen'));
+        $this->tool = ToolActivation::find([$context->id, $module_id]);
+        if (Request::isPost()) {
+            $metadata = $this->tool->metadata->getArrayCopy();
+            if (!trim(Request::get('displayname')) || Request::submitted('delete')) {
+                unset($metadata['displayname']);
+            } else {
+                $metadata['displayname'] = trim(Request::get('displayname'));
+            }
+            $this->tool['metadata'] = $metadata;
+            $this->tool->store();
+            $this->redirect('course/contentmodules/index');
+        }
+    }
+
+    public function info_action($plugin_id)
+    {
+        $this->plugin = PluginManager::getInstance()->getPluginById($plugin_id);
+        $this->metadata = $this->plugin->getMetadata();
+        PageLayout::setTitle(sprintf(_('Informationen über %s'), $this->metadata['displayname']));
+    }
+
+    private function getModules(Range $context)
+    {
+        $list = [];
+
+        foreach (PluginEngine::getPlugins('StudipModule') as $plugin) {
+            if (!$plugin->isActivatableForContext($context)) {
+                continue;
+            }
+
+            if (!$this->sem_class->isModuleAllowed(get_class($plugin))) {
+                continue;
+            }
+
+            $info = $plugin->getMetadata();
+
+            $plugin_id = $plugin->getPluginId();
+
+            $tool = ToolActivation::find([$context->getRangeId(), $plugin->getPluginId()]);
+            $toolname = $info['displayname'] ?? $plugin->getPluginname();
+            if ($tool && $tool->metadata['displayname']) {
+                $displayname = $tool->getDisplayname() . ' (' . $toolname . ')';
+            } else {
+                $displayname = $toolname;
+            }
+            $visibility = $tool ? $tool->getVisibilityPermission() : 'nobody';
+
+            $metadata = $plugin->getMetadata();
+            $list[$plugin_id] = [
+                'id'          => $plugin_id,
+                'moduleclass' => get_class($plugin),
+                'position'    => $tool ? $tool->position : null,
+                'toolname'    => $toolname,
+                'displayname' => $displayname,
+                'visibility'  => $visibility,
+                'active'      => (bool) $tool,
+            ];
+            if ($metadata['icon_clickable']) {
+                $list[$plugin_id]['icon'] = $metadata['icon_clickable'] instanceof Icon
+                    ? $metadata['icon_clickable']->asImagePath()
+                    : Icon::create($plugin->getPluginURL().'/'.$metadata['icon_clickable'])->asImagePath();
+            } elseif ($metadata['icon']) {
+                $list[$plugin_id]['icon'] = $metadata['icon'] instanceof Icon
+                    ? $metadata['icon']->asImagePath()
+                    : Icon::create($plugin->getPluginURL().'/'.$metadata['icon'])->asImagePath();
+            } else {
+                $list[$plugin_id]['icon'] = null;
+            }
+            $list[$plugin_id]['summary'] = $metadata['summary'];
+            $list[$plugin_id]['mandatory'] = $this->sem_class->isModuleMandatory(get_class($plugin));
+            $list[$plugin_id]['highlighted'] = (bool) $plugin->isHighlighted();
+            $list[$plugin_id]['highlight_text'] = $plugin->getHighlightText();
+            $list[$plugin_id]['category'] = $metadata['category'];
+        }
+
+        return $list;
+    }
+}
diff --git a/app/controllers/course/plus.php b/app/controllers/course/plus.php
deleted file mode 100644
index 1a5ac26b1b8..00000000000
--- a/app/controllers/course/plus.php
+++ /dev/null
@@ -1,399 +0,0 @@
-<?php
-# Lifter001: TODO
-/*
- * Copyright (C) 2012 - Rasmus Fuhse <fuhse@data-quest.de>
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU General Public License as
- * published by the Free Software Foundation; either version 2 of
- * the License, or (at your option) any later version.
- */
-
-use Studip\Button, Studip\LinkButton;
-
-class Course_PlusController extends AuthenticatedController
-{
-
-    public function before_filter(&$action, &$args)
-    {
-        parent::before_filter($action, $args);
-        $id = Context::get()->getId();
-        $object_type = Context::getType();
-        if (!$id || !$GLOBALS['perm']->have_studip_perm($object_type === 'course' ? 'tutor' : 'admin', $id)) {
-            throw new AccessDeniedException();
-        }
-        Navigation::activateItem('/course/modules');
-
-        if ($object_type === 'course') {
-            $this->sem = Context::get();
-            $this->sem_class = $this->sem->getSemClass();
-        } else {
-            $this->sem = Context::get();
-            $this->sem_class = SemClass::getDefaultInstituteClass($this->sem['type']);
-        }
-        PageLayout::setTitle(_("Mehr Funktionen"));
-    }
-
-    public function index_action()
-    {
-
-        PageLayout::setTitle($this->sem->getFullname() . " - " . PageLayout::getTitle());
-        PageLayout::addSqueezePackage('statusgroups'); //sortier css
-
-        $this->setupSidebar();
-        $this->available_modules = $this->getSortedList($this->sem);
-
-        if (Request::submitted('deleteContent')) {
-            $this->deleteContent($this->available_modules);
-        }
-    }
-
-    public function trigger_action()
-    {
-        $context = Context::get();
-
-        if (!$GLOBALS['perm']->have_studip_perm($context->getRangeType() === 'course' ? 'tutor' : 'admin', $context->getId())) {
-            throw new AccessDeniedException();
-        }
-        if (Request::isPost()) {
-            if ($context->getRangeType() === 'course') {
-                $sem_class = $context->getSemClass();
-            } else {
-                $sem_class = SemClass::getDefaultInstituteClass($context->type);
-            }
-            $moduleclass = Request::get("moduleclass");
-            $active = Request::int("active", 0);
-            $module = new $moduleclass;
-            if ($module->isActivatableForContext($context)) {
-                PluginManager::getInstance()->setPluginActivated($module->getPluginId(), $context->getId(), $active);
-                if (Context::isCourse()) {
-                    if ($active) {
-                        StudipLog::log('PLUGIN_ENABLE', Context::getId(), $module->getPluginId(), $GLOBALS['user']->id);
-                        NotificationCenter::postNotification('PluginDidActivate', Context::getId(), $module->getPluginId());
-                    } else {
-                        StudipLog::log('PLUGIN_DISABLE', Context::getId(), $module->getPluginId(), $GLOBALS['user']->id);
-                        NotificationCenter::postNotification('PluginDidDeactivate', Context::getId(), $module->getPluginId());
-                    }
-                }
-            }
-            if ($active) {
-                $default_position = array_search(get_class($module), $sem_class->getActivatedModules());
-                if ($default_position !== false) {
-                    $active_tool = ToolActivation::find([$context->getId(), $module->getPluginId()]);
-                    if ($active_tool) {
-                        $active_tool->position = $default_position;
-                        $active_tool->store();
-                    }
-                }
-            }
-            $this->redirect("course/plus/trigger", ['cid' => $context->getId()]);
-        } else {
-            $template = $GLOBALS['template_factory']->open("tabs.php");
-            $template->navigation = Navigation::getItem("/course");
-            $this->render_json([
-                'tabs' => $template->render()
-            ]);
-        }
-    }
-
-    public function sorttools_action()
-    {
-        PageLayout::setTitle(_('Reihenfolge der Werkzeuge ändern'));
-        if (Request::submitted('order')) {
-            CSRFProtection::verifyUnsafeRequest();
-            $plugin_id = explode('_', Request::get('id'))[1];
-            $newpos = Request::get('index') + 1;
-            if ($this->sem->tools->findOneBy('plugin_id', $plugin_id)) {
-                $oldpos = $this->sem->tools->findOneBy('plugin_id', $plugin_id)->position;
-                if ($oldpos < $newpos) {
-                    $this->sem->tools->findBy('position', $newpos, '>')->each(function ($p) {
-                        $p->position++;
-                    });
-                    $this->sem->tools->findOneBy('plugin_id', $plugin_id)->position = $newpos + 1;
-                } else {
-                    $this->sem->tools->findBy('position', $newpos, '>=')->each(function ($p) {
-                        $p->position++;
-                    });
-                    $this->sem->tools->findOneBy('plugin_id', $plugin_id)->position = $newpos;
-                }
-                $this->sem->tools->orderBy('position asc')->each(function ($p) {static $pos = 0; $p->position = $pos++;});
-                $this->sem->tools->store();
-                $this->render_nothing();
-                return;
-            }
-        }
-
-    }
-
-    public function edittool_action($plugin)
-    {
-        PageLayout::setTitle(_('Optionen des Werkzeugs ändern'));
-        $id = explode('_', $plugin)[1];
-        $this->tool = ToolActivation::find([$this->sem->id, $id]);
-        if (!$this->tool) {
-            $this->render_nothing();
-            return;
-        }
-        if (Request::submitted('save')) {
-            CSRFProtection::verifyUnsafeRequest();
-            $displayname = trim(Request::get('displayname'));
-            if ($displayname !== $this->tool->getDisplayname()) {
-                if (strlen($displayname)) {
-                    $this->tool->metadata['displayname'] = $displayname;
-                } else {
-                    unset($this->tool->metadata['displayname']);
-                }
-
-            }
-            if (Request::get('permission') === 'tutor') {
-                $this->tool->metadata['visibility'] = 'tutor';
-            } else {
-                unset($this->tool->metadata['visibility']);
-            }
-            if ($this->tool->store()) {
-                PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.'));
-            }
-            $this->redirect($this->action_url('index'));
-        }
-    }
-
-
-    private function deleteContent($plugmodlist)
-    {
-        $name = Request::get('name');
-
-        foreach ($plugmodlist as $key => $val) {
-            if (array_key_exists($name, $val)) {
-                if ($val[$name]['type'] == 'plugin') {
-                    $class = PluginEngine::getPlugin(get_class($val[$name]['object']));
-                    $displayname = $class->getPluginName();
-                }
-            }
-        }
-
-        if (Request::submitted('check')) {
-            if (method_exists($class, 'deleteContent')) {
-                $class->deleteContent();
-            } else {
-                PageLayout::postMessage(MessageBox::info(_("Das Plugin/Modul enthält keine Funktion zum Löschen der Inhalte.")));
-            }
-        } else {
-            PageLayout::postMessage(MessageBox::info(sprintf(_("Sie beabsichtigen die Inhalte von %s zu löschen."), htmlReady($displayname))
-                . "<br>" . _("Wollen Sie die Inhalte wirklich löschen?") . "<br>"
-                . LinkButton::createAccept(_('Ja'), URLHelper::getURL("?deleteContent=true&check=true&name=" . $name))
-                . LinkButton::createCancel(_('Nein'))));
-        }
-    }
-
-    private function setupSidebar()
-    {
-
-        $plusconfig = UserConfig::get($GLOBALS['user']->id)->PLUS_SETTINGS;
-
-        if (!isset($_SESSION['plus'])) {
-            if (isset($plusconfig['course_plus'])){
-                $usr_conf = $plusconfig['course_plus'];
-
-                $_SESSION['plus']['Kategorie']['Lehr- und Lernorganisation'] = $usr_conf['Kategorie']['Lehr- und Lernorganisation'];
-                $_SESSION['plus']['Kategorie']['Kommunikation und Zusammenarbeit'] = $usr_conf['Kategorie']['Kommunikation und Zusammenarbeit'];
-                $_SESSION['plus']['Kategorie']['Inhalte und Aufgabenstellungen'] = $usr_conf['Kategorie']['Inhalte und Aufgabenstellungen'];
-                $_SESSION['plus']['Kategorie']['Sonstiges'] = $usr_conf['Kategorie']['Sonstiges'];
-
-                foreach ($usr_conf['Kategorie'] as $key => $val){
-                    if(!array_key_exists($key, $_SESSION['plus']['Kategorie'])){
-                        $_SESSION['plus']['Kategorie'][$key] = $val;
-                    }
-                }
-
-                $_SESSION['plus']['View'] = $usr_conf['View'];
-                $_SESSION['plus']['displaystyle'] = $usr_conf['displaystyle'];
-
-            } else {
-                $_SESSION['plus']['Kategorie']['Lehr- und Lernorganisation'] = 1;
-                $_SESSION['plus']['Kategorie']['Kommunikation und Zusammenarbeit'] = 1;
-                $_SESSION['plus']['Kategorie']['Inhalte und Aufgabenstellungen'] = 1;
-                $_SESSION['plus']['Kategorie']['Sonstiges'] = 1;
-                $_SESSION['plus']['View'] = 'openall';
-                $_SESSION['plus']['displaystyle'] = 'category';
-            }
-        }
-
-        if(isset($_SESSION['plus']['Kategorielist'])){
-            foreach ($_SESSION['plus']['Kategorie'] as $key => $val){
-                if(!array_key_exists($key, $_SESSION['plus']['Kategorielist']) && $key != 'Sonstiges'){
-                    unset($_SESSION['plus']['Kategorie'][$key]);
-                }
-            }
-        }
-        if (Request::get('mode') !== null) {
-            $_SESSION['plus']['View'] = Request::get('mode');
-        }
-        if (Request::get('displaystyle') !== null) {
-            $_SESSION['plus']['displaystyle'] = Request::get('displaystyle');
-        }
-
-        $sidebar = Sidebar::get();
-
-        $widget = new OptionsWidget();
-        $widget->setTitle(_('Kategorien'));
-
-        foreach ($_SESSION['plus']['Kategorie'] as $key => $val) {
-
-            if (Request::get(md5('cat_' . $key)) !== null) {
-                $_SESSION['plus']['Kategorie'][$key] = Request::get(md5('cat_' . $key));
-            }
-
-            if ($_SESSION['plus']['displaystyle'] == 'alphabetical') {
-                $_SESSION['plus']['Kategorie'][$key] = 1;
-            }
-
-            if ($key == 'Sonstiges') {
-                continue;
-            }
-            $widget->addCheckbox(
-                $key,
-                $_SESSION['plus']['Kategorie'][$key],
-                URLHelper::getURL('?', [md5('cat_' . $key) => 1, 'displaystyle' => 'category']),
-                URLHelper::getURL('?', [md5('cat_' . $key) => 0, 'displaystyle' => 'category'])
-            );
-
-        }
-
-        $widget->addCheckbox(
-            _('Sonstiges'),
-            $_SESSION['plus']['Kategorie']['Sonstiges'],
-            URLHelper::getURL('?', [md5('cat_Sonstiges') => 1, 'displaystyle' => 'category']),
-            URLHelper::getURL('?', [md5('cat_Sonstiges') => 0, 'displaystyle' => 'category'])
-        );
-
-        $sidebar->addWidget($widget, 'Kategorien');
-
-        $widget = new ActionsWidget();
-        $widget->setTitle(_('Ansichten'));
-
-        if ($_SESSION['plus']['View'] === 'openall') {
-            $widget->addLink(
-                _('Alles zuklappen'),
-                URLHelper::getURL('?', ['mode' => 'closeall']),
-                Icon::create('assessment')
-            );
-        } else {
-            $widget->addLink(
-                _('Alles aufklappen'),
-                URLHelper::getURL('?', ['mode' => 'openall']),
-                Icon::create('assessment')
-            );
-        }
-
-        if ($_SESSION['plus']['displaystyle'] === 'category') {
-            $widget->addLink(
-                _('Alphabetische Anzeige ohne Kategorien'),
-                URLHelper::getURL('?', ['displaystyle' => 'alphabetical']),
-                Icon::create('assessment')
-            );
-        } else {
-            $widget->addLink(
-                _('Anzeige nach Kategorien'),
-                URLHelper::getURL('?', ['displaystyle' => 'category']),
-                Icon::create('assessment')
-            );
-        }
-
-        $sidebar->addWidget($widget, 'ansicht');
-
-        $actions = new ActionsWidget();
-        $actions->addLink(
-            _('Werkzeugreihenfolge ändern'),
-            $this->action_url('sorttools'),
-            Icon::create('arr_2down')
-        )->asDialog('size=500;reload-on-close');
-
-        $sidebar->addWidget($actions, 'aktion');
-
-
-        unset($_SESSION['plus']['Kategorielist']);
-        $plusconfig['course_plus'] = $_SESSION['plus'];
-        UserConfig::get($GLOBALS['user']->id)->store('PLUS_SETTINGS', $plusconfig);
-    }
-
-    private function getSortedList(Range $context)
-    {
-
-        $list = [];
-        $cat_index = [];
-
-        foreach (PluginEngine::getPlugins('StudipModule') as $plugin) {
-            if (!$plugin->isActivatableForContext($context)) {
-                continue;
-            }
-
-
-
-            if (!$this->sem_class->isModuleMandatory(get_class($plugin))
-                    && $this->sem_class->isModuleAllowed(get_class($plugin))
-            ) {
-
-                $info = $plugin->getMetadata();
-
-                $indcat = isset($info['category']) ? $info['category'] : 'Sonstiges';
-                if (!array_key_exists($indcat, $cat_index)) {
-                    array_push($cat_index, $indcat);
-                }
-                $plugin_id = 'plugin_' . $plugin->getPluginId();
-                $tool = ToolActivation::find([$context->getRangeId(), $plugin->getPluginId()]);
-                $displayname = $info['displayname'] ?? $plugin->getPluginname();
-                if ($tool && $tool->metadata['displayname']) {
-                    $displayname .= ' (' .$tool->getDisplayname() . ')';
-                }
-                $visibility = $tool && $tool->metadata['visibility'] ? $tool->metadata['visibility'] : 'autor';
-
-                if ($_SESSION['plus']['displaystyle'] != 'category') {
-
-
-                    $list['Funktionen von A-Z'][$plugin_id]['object'] = $plugin;
-                    $list['Funktionen von A-Z'][$plugin_id]['type'] = 'plugin';
-                    $list['Funktionen von A-Z'][$plugin_id]['moduleclass'] = get_class($plugin);
-                    $list['Funktionen von A-Z'][$plugin_id]['sorter'] = mb_strtolower($displayname);
-                    $list['Funktionen von A-Z'][$plugin_id]['displayname'] = $displayname;
-                    $list['Funktionen von A-Z'][$plugin_id]['visibility'] = $visibility;
-                } else {
-
-                    $cat = isset($info['category']) ? $info['category'] : 'Sonstiges';
-
-                    if (!isset($_SESSION['plus']['Kategorie'][$cat])) {
-                        $_SESSION['plus']['Kategorie'][$cat] = 1;
-                    }
-
-                    $list[$cat][$plugin_id]['object'] = $plugin;
-                    $list[$cat][$plugin_id]['moduleclass'] = get_class($plugin);
-                    $list[$cat][$plugin_id]['type'] = 'plugin';
-                    $list[$cat][$plugin_id]['sorter'] = mb_strtolower($displayname);
-                    $list[$cat][$plugin_id]['displayname'] = $displayname;
-                    $list[$cat][$plugin_id]['visibility'] = $visibility;
-                }
-            }
-        }
-
-        $sortedcats['Lehr- und Lernorganisation'] = [];
-        $sortedcats['Kommunikation und Zusammenarbeit'] = [];
-        $sortedcats['Inhalte und Aufgabenstellungen'] = [];
-
-        foreach ($list as $cat_key => $cat_val) {
-            uasort($cat_val, function ($a, $b) {return strcmp($a['sorter'], $b['sorter']);});
-            $list[$cat_key] = $cat_val;
-            if ($cat_key != 'Sonstiges')  {
-                $sortedcats[$cat_key] = $list[$cat_key];
-            }
-        }
-
-        if (isset($list['Sonstiges'])) {
-            $sortedcats['Sonstiges'] = $list['Sonstiges'];
-        }
-
-
-        $_SESSION['plus']['Kategorielist'] = array_flip($cat_index);
-
-        return $sortedcats;
-    }
-
-}
diff --git a/app/views/admin/plugin/edit_description.php b/app/views/admin/plugin/edit_description.php
new file mode 100644
index 00000000000..45a48d133d2
--- /dev/null
+++ b/app/views/admin/plugin/edit_description.php
@@ -0,0 +1 @@
+<?= $form->render() ?>
diff --git a/app/views/admin/plugin/index.php b/app/views/admin/plugin/index.php
index 1d51d8de08d..c2a2034af33 100644
--- a/app/views/admin/plugin/index.php
+++ b/app/views/admin/plugin/index.php
@@ -104,6 +104,16 @@ use Studip\Button, Studip\LinkButton;
                                 _('Zugriffsrechte bearbeiten'),
                                 Icon::create('edit', 'clickable', ['title' => _('Zugriffsrechte bearbeiten')])
                             ) ?>
+                            <?
+                            if (in_array('StudipModule', $plugin['type'])) {
+                                $actionMenu->addLink(
+                                    $controller->url_for('admin/plugin/edit_description/' . $pluginid),
+                                    _('Beschreibung und Hervorhebung'),
+                                    Icon::create('infopage', Icon::ROLE_CLICKABLE, ['title' => _('Beschreibung und Hervorhebung')]),
+                                    ['data-dialog' => 'size=big']
+                                );
+                            }
+                            ?>
 
                             <? if (!$plugin['depends'] && isset($update_info[$pluginid]['version']) && !$plugin['core']): ?>
                                 <? $actionMenu->addLink(
diff --git a/app/views/course/contentmodules/index.php b/app/views/course/contentmodules/index.php
new file mode 100644
index 00000000000..af8f3e1ebaf
--- /dev/null
+++ b/app/views/course/contentmodules/index.php
@@ -0,0 +1 @@
+<div class="content-modules-vue-app" is="ContentModules"></div>
diff --git a/app/views/course/contentmodules/info.php b/app/views/course/contentmodules/info.php
new file mode 100644
index 00000000000..63374e3f35d
--- /dev/null
+++ b/app/views/course/contentmodules/info.php
@@ -0,0 +1,56 @@
+<? if ($plugin->getDescriptionMode() === 'replace_all') : ?>
+    <?= formatReady($plugin->getPluginDescription()) ?>
+<? else : ?>
+    <div class="contentmodule_info">
+        <div class="main_part">
+            <div class="header">
+                <div class="image">
+                    <?
+                    $icon = $metadata['icon'];
+                    if (!$icon) {
+                        $icon = Icon::create('plugin', Icon::ROLE_INFO);
+                    }
+                    if (!is_a($icon, 'Icon')) {
+                        $icon = Icon::create($icon);
+                    }
+                    ?>
+                    <?= $icon->asImg(100) ?>
+                </div>
+                <div class="text">
+                    <h1><?= htmlReady($metadata['displayname'] ?? $plugin->getPluginName()) ?></h1>
+                    <strong>
+                        <?= htmlReady($metadata['summary']) ?>
+                    </strong>
+                </div>
+            </div>
+            <div class="content-modules-controls-vue-app" is="ContentModulesControl" module_id="<?= htmlReady($plugin->getPluginId()) ?>"></div>
+            <? $keywords = preg_split( "/;/", $metadata['keywords'], -1, PREG_SPLIT_NO_EMPTY) ?>
+            <? if (count($keywords)) : ?>
+            <ul class="keywords">
+                <? foreach ($keywords as $keyword) : ?>
+                <li>
+                    <?= htmlReady($keyword) ?>
+                </li>
+                <? endforeach ?>
+            </ul>
+            <? endif ?>
+            <div class="description">
+                <?= formatReady($plugin->getPluginDescription()) ?>
+            </div>
+        </div>
+        <? if (isset($metadata['screenshots']) && count($metadata['screenshots']['pictures'])) : ?>
+        <ul class="screenshots clean">
+            <? foreach ($metadata['screenshots']['pictures'] as $pictures) : ?>
+            <li>
+                <a href="<?= $plugin->getPluginURL().$metadata['screenshots']['path'].'/'.$pictures['source'] ?>"
+                   data-lightbox="<?= htmlReady($metadata['displayname'] ?? $plugin->getPluginName()) ?>"
+                   data-title="<?= htmlReady($pictures['title']) ?>">
+                    <img src="<?= $plugin->getPluginURL().$metadata['screenshots']['path'].'/'.$pictures['source'] ?>" alt="">
+                    <?= htmlReady($pictures['title']) ?>
+                </a>
+            </li>
+            <? endforeach ?>
+        </ul>
+        <? endif ?>
+    </div>
+<? endif ?>
diff --git a/app/views/course/contentmodules/rename.php b/app/views/course/contentmodules/rename.php
new file mode 100644
index 00000000000..c7f4ef7ccd8
--- /dev/null
+++ b/app/views/course/contentmodules/rename.php
@@ -0,0 +1,24 @@
+<form class="default"
+      action="<?= $controller->link_for('course/contentmodules/rename/' . $module->getPluginId()) ?>"
+      method="post">
+    <fieldset>
+
+        <label>
+            <?= _('Neuer Name des Werkzeugs') ?>
+            <input type="text"
+                   name="displayname"
+                   value="<?= $tool && $tool['metadata'] ? htmlReady($tool['metadata']['displayname']) : ''?>"
+                   placeholder="<?= htmlReady($metadata['displayname']) ?>">
+        </label>
+
+        <div>
+            <?= htmlReady(sprintf(_('Ursprünglicher Werkzeugname ist "%s".'), $metadata['displayname'])) ?>
+        </div>
+    </fieldset>
+    <div data-dialog-button>
+        <?= \Studip\Button::create(_('Speichern'))?>
+        <? if ($tool && $tool['metadata'] && $tool['metadata']['displayname']) : ?>
+            <?= \Studip\Button::create(_('Namen löschen'), 'delete') ?>
+        <? endif ?>
+    </div>
+</form>
diff --git a/app/views/course/plus/edittool.php b/app/views/course/plus/edittool.php
deleted file mode 100644
index 124d2b45e12..00000000000
--- a/app/views/course/plus/edittool.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<form class="default" action="<?=$controller->action_link('edittool/plugin_' . $tool->plugin_id)?>" method="post">
-    <?= CSRFProtection::tokenTag() ?>
-    <fieldset>
-        <label for="displayname">
-            <?=_('Name des Werkzeugs')?>
-            <input type="text" name="displayname" id="displayname" value="<?=htmlReady($tool->getDisplayname())?>">
-        </label>
-
-        <label><?=_('Sichtbarkeit')?></label>
-        <div class="hgroup">
-            <label for="permission_autor">
-                <?=_('Studierende')?>
-                <input type="radio" name="permission" id="permission_autor" value="autor" checked>
-            </label>
-            <label for="permission_tutor">
-                <?=_('Lehrende')?>
-                <input type="radio" name="permission" id="permission_tutor" value="tutor" <?= $tool->getVisibilityPermission() === 'tutor' ? 'checked' : '' ?>>
-            </label>
-        </div>
-    </fieldset>
-
-    <footer data-dialog-button>
-        <?= Studip\Button::createAccept(_('Speichern'), 'save') ?>
-    </footer>
-</form>
diff --git a/app/views/course/plus/index.php b/app/views/course/plus/index.php
deleted file mode 100644
index adf58b2ee1a..00000000000
--- a/app/views/course/plus/index.php
+++ /dev/null
@@ -1,245 +0,0 @@
-<?
-
-/*
- *  Copyright (c) 2012  Rasmus Fuhse <fuhse@data-quest.de>
- *
- *  This program is free software; you can redistribute it and/or
- *  modify it under the terms of the GNU General Public License as
- *  published by the Free Software Foundation; either version 2 of
- *  the License, or (at your option) any later version.
- */
-
-use Studip\Button;
-
-?>
-
-<form action="<?= URLHelper::getLink() ?>" method="post" class="default">
-    <?= CSRFProtection::tokenTag() ?>
-    <input name="uebernehmen" value="1" type="hidden">
-    <table class="default nohover plus">
-        <!-- <caption><?= _("Inhaltselemente") ?></caption> -->
-        <tbody>
-            <? foreach ($available_modules as $category => $pluginlist) : ?>
-                <?
-                $visibility = '';
-                if ($_SESSION['plus']['displaystyle'] != 'category' && $category != 'Funktionen von A-Z') {
-                    $visibility = 'invisible';
-                }
-                if (isset($_SESSION['plus']) && empty($_SESSION['plus']['Kategorie'][$category]) && $category != 'Funktionen von A-Z') {
-                    $visibility = 'invisible';
-                }
-                ?>
-                <tr class="<?= $visibility; ?>">
-                    <th colspan=3>
-                        <?= htmlReady($category) ?>
-                    </th>
-                </tr>
-                <? foreach ($pluginlist as $key => $val) : ?>
-                    <?
-                    if ($val['type'] == 'plugin') {
-                        $plugin           = $val['object'];
-                        $plugin_activated = $plugin->isActivated();
-                        $info             = $plugin->getMetadata();
-
-                        //Checkbox
-                        $anchor      = 'p_' . $plugin->getPluginId();
-                        $cb_disabled = '';
-                        $cb_checked  = $plugin_activated ? "checked" : "";
-
-                        $pluginname       = $val['displayname'];
-                        $url              = $plugin->isCorePlugin() ? $GLOBALS['ABSOLUTE_URI_STUDIP'] : $plugin->getPluginURL();
-                        $pluginvisibility = $val['visibility'];
-                    }
-                    ?>
-
-                    <tr id="<?= htmlReady($anchor); ?>"
-                        class="<?= $visibility; ?>">
-                        <td class="element" colspan=3>
-
-                            <div class="plus_basic">
-                                <input type="checkbox"
-                                       id="<?= $key ?>"
-                                       name="<?= $key ?>"
-                                       data-moduleclass="<?= htmlReady($val['moduleclass']) ?>"
-                                       data-key="<?= htmlReady($val['modulkey'] ?? '') ?>"
-                                       value="TRUE" <?= $cb_disabled ?> <?= $cb_checked ?>
-                                       onClick="STUDIP.Plus.setModule.call(this);">
-                                <div class="element_header">
-                                    <!-- Name -->
-                                    <label for="<?= $key ?>">
-                                        <strong><?= htmlReady($pluginname) ?></strong>
-                                        <? if ($cb_checked) : ?>
-                                            <?= Icon::create(
-                                                $pluginvisibility === 'autor' ? 'visibility-visible' : 'visibility-invisible',
-                                                Icon::ROLE_INFO,
-                                                [
-                                                    'title' => sprintf(
-                                                        _('%s für Studierende'),
-                                                        $pluginvisibility === 'autor' ? _('Sichtbar') : _('Unsichtbar')
-                                                    )
-                                                ]
-                                            ) ?>
-                                        <? endif ?>
-                                    </label>
-                                </div>
-                                <div class="element_description">
-                                    <? if (isset($info['icon'])) : ?>
-                                        <? /* TODO: Plugins should use class "Icon"  */ ?>
-                                        <? if (is_string($info['icon'])) : ?>
-                                            <img class="plugin_icon text-bottom" alt=""
-                                                 src="<?= htmlReady($url . "/" . $info['icon']) ?> ">
-                                        <? else: ?>
-                                            <?= $info['icon']->asImg(['class' => 'plugin_icon text-bottom', 'alt' => '']) ?>
-                                        <? endif ?>
-                                    <? endif ?>
-                                    <strong class="shortdesc">
-                                        <? if (isset($info['descriptionshort'])) : ?>
-                                            <? foreach (explode('\n', $info['descriptionshort']) as $descriptionshort) : ?>
-                                                <?= htmlReady($descriptionshort) ?>
-                                            <? endforeach ?>
-                                        <? endif ?>
-                                        <? if (!isset($info['descriptionshort'])) : ?>
-                                            <? if (isset($info['summary'])) : ?>
-                                                <?= htmlReady($info['summary']) ?>
-                                            <? elseif (isset($info['description'])) : ?>
-                                                <?= htmlReady($info['description']) ?>
-                                            <? else: ?>
-                                                <?= _('Keine Beschreibung vorhanden.') ?>
-                                            <? endif ?>
-                                        <? endif ?>
-                                    </strong>
-                                </div>
-                                <? if ($plugin_activated) : ?>
-                                    <?
-                                    $actionMenu = ActionMenu::get()->setContext($pluginname);
-                                    $actionMenu->addLink(
-                                        $controller->action_url('edittool/' . $key),
-                                        _('Optionen bearbeiten'),
-                                        Icon::create('edit'),
-                                        ['data-dialog' => 'size=auto']
-                                    );
-                                    if (method_exists($plugin, 'deleteContent')) {
-                                        $actionMenu->addLink(
-                                            $controller->action_url('index', ['deleteContent' => 1, 'name' => $key]),
-                                            _('Inhalte löschen'),
-                                            Icon::create('trash')
-                                        );
-                                    }
-                                    ?>
-                                    <div style="float: right">
-                                        <?= $actionMenu->render() ?>
-                                    </div>
-                                <? endif ?>
-                            </div>
-
-                            <? if ($_SESSION['plus']['View'] === 'openall' || !isset($_SESSION['plus'])) : ?>
-                                <div class="plus_expert hidden-tiny-down">
-                                    <div class="screenshot_holder">
-                                        <? if (isset($info['screenshot']) || isset($info['screenshots'])) :
-                                            if (isset($info['screenshots'])) {
-                                                $title  = $info['screenshots']['pictures'][0]['title']??'';
-                                                $source = $info['screenshots']['path'] . '/' . $info['screenshots']['pictures'][0]['source'];
-                                            } else {
-                                                $fileext = pathinfo($info['screenshot'], PATHINFO_EXTENSION);
-                                                $title   = str_replace('_', ' ', basename($info['screenshot'], ".$fileext"));
-                                                $source  = $info['screenshot'];
-                                            }
-                                            ?>
-
-                                            <a href="<?= htmlReady("$url/$source") ?>"
-                                               data-lightbox="<?= htmlReady($pluginname) ?>"
-                                               data-title="<?= htmlReady($title) ?>">
-                                                <img class="big_thumb" src="<?= htmlReady("$url/$source") ?>"
-                                                     alt="<?= htmlReady($pluginname) ?>"/>
-                                            </a>
-
-                                            <? if (isset($info['additionalscreenshots'])
-                                                || (isset($info['screenshots']) && count($info['screenshots']) > 1)) :?>
-                                                <div class="thumb_holder">
-                                                    <?
-                                                        if (isset($info['screenshots'])) {
-                                                            $counter = count($info['screenshots']['pictures']);
-                                                            $cstart  = 1;
-                                                        } else {
-                                                            $counter = count($info['additionalscreenshots']);
-                                                            $cstart  = 0;
-                                                        }
-                                                    ?>
-
-                                                    <? for ($i = $cstart; $i < $counter; $i++) :?>
-                                                        <?
-                                                            if (isset($info['screenshots'])) {
-                                                                $title  = $info['screenshots']['pictures'][$i]['title']?? '';
-                                                                $source = $info['screenshots']['path'] . '/' . $info['screenshots']['pictures'][$i]['source'];
-                                                            } else {
-                                                                $fileext = pathinfo($info['additionalscreenshots'][$i], PATHINFO_EXTENSION);
-                                                                $title   = str_replace('_', ' ', basename($info['additionalscreenshots'][$i], ".$fileext"));
-                                                                $source  = $info['additionalscreenshots'][$i];
-                                                            }
-                                                        ?>
-                                                        <a href="<?= htmlReady("$url/$source") ?>"
-                                                           data-lightbox="<?= htmlReady($pluginname) ?>"
-                                                           data-title="<?= htmlReady($title) ?>">
-                                                            <img class="small_thumb"
-                                                                 src="<?= htmlReady("$url/$source") ?>"
-                                                                 alt="<?= htmlReady($pluginname) ?>">
-                                                        </a>
-                                                    <? endfor ?>
-                                                </div>
-                                            <? endif ?>
-                                        <? endif ?>
-                                    </div>
-                                    <div class="descriptionbox">
-                                        <? if (isset($info['keywords'])) : ?>
-                                            <ul class="keywords">
-                                                <? foreach (explode(';', $info['keywords']) as $keyword) : ?>
-                                                    <li><?= htmlReady($keyword) ?> </li>
-                                                <? endforeach ?>
-                                            </ul>
-                                        <? endif ?>
-                                        <? if (isset($info['descriptionlong'])) : ?>
-                                            <? foreach (explode('\n', $info['descriptionlong']) as $descriptionlong) : ?>
-                                                <p class="longdesc">
-                                                    <?= htmlReady($descriptionlong) ?>
-                                                </p>
-                                            <? endforeach ?>
-                                        <? endif ?>
-                                        <? if (!isset($info['descriptionlong']) && isset($info['summary'])) : ?>
-                                            <p class="longdesc">
-                                                <? if (isset($info['description'])) : ?>
-                                                    <?= htmlReady($info['description']) ?>
-                                                <? else: ?>
-                                                    <?= _('Keine Beschreibung vorhanden.') ?>
-                                                <? endif ?>
-                                            </p>
-                                        <? endif ?>
-                                        <? if (isset($info['homepage'])) : ?>
-                                            <p>
-                                                <strong><?= _('Weitere Informationen:') ?></strong>
-                                                <a href="<?= htmlReady($info['homepage']) ?>">
-                                                    <?= htmlReady($info['homepage']) ?>
-                                                </a>
-                                            </p>
-                                        <? endif ?>
-                                        <? if (isset($info['helplink'])) : ?>
-                                            <a class="helplink" href=" <?= htmlReady($info['helplink']) ?> ">
-                                                ...<?= _('mehr') ?>
-                                            </a>
-                                        <? endif ?>
-                                    </div>
-                                </div>
-                            <? endif ?>
-                        </td>
-                    </tr>
-                <? endforeach ?>
-            <? endforeach ?>
-        </tbody>
-        <tfoot>
-            <tr class="hidden-js">
-                <td colspan="3">
-                    <?= Button::create(_('An- / Ausschalten'), 'uebernehmen') ?>
-                </td>
-            </tr>
-        </tfoot>
-    </table>
-</form>
diff --git a/app/views/course/plus/sorttools.php b/app/views/course/plus/sorttools.php
deleted file mode 100644
index 153e7c9f8ff..00000000000
--- a/app/views/course/plus/sorttools.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<section class="contentbox course-statusgroups" data-sortable="<?=$controller->action_link('sorttools', ['order' => 1]) ?>">
-<? if ($sem->tools): ?>
-    <? foreach ($sem->tools as $tool): ?>
-    <?php if (!$tool->getStudipModule()) continue; ?>
-        <article class="draggable" id="plugin_<?= $tool->plugin_id ?>">
-            <header>
-                <span class="drag-handle"></span>
-                <h1><?= htmlready($tool->getDisplayName()) ?></h1>
-            </header>
-        </article>
-    <? endforeach ?>
-<? endif ?>
-</section>
-
-
diff --git a/db/migrations/5.4.10_contentmodules_description.php b/db/migrations/5.4.10_contentmodules_description.php
new file mode 100644
index 00000000000..266be87662e
--- /dev/null
+++ b/db/migrations/5.4.10_contentmodules_description.php
@@ -0,0 +1,47 @@
+<?php
+final class ContentmodulesDescription extends Migration
+{
+    public function description()
+    {
+        return 'Content modules of a course, institute or studygroup got revamped.';
+    }
+
+    protected function up()
+    {
+        $query = "ALTER TABLE `plugins`
+                  ADD COLUMN `description` TEXT DEFAULT NULL,
+                  ADD COLUMN `description_mode` ENUM('add', 'override_description', 'replace_all') DEFAULT 'add',
+                  ADD COLUMN `highlight_until` INT(11) UNSIGNED DEFAULT NULL,
+                  ADD COLUMN `highlight_text` VARCHAR(64) DEFAULT NULL,
+                  ADD KEY `highlight_until` (`highlight_until`)";
+        DBManager::get()->exec($query);
+
+        $query = "INSERT IGNORE INTO `config` (`field`, `value`, `type`, `range`, `mkdate`, `chdate`, `description`)
+                  VALUES (:name, :value, :type, :range, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description)";
+
+        $statement = DBManager::get()->prepare($query);
+        $statement->execute([
+            ':name'        => 'CONTENTMODULES_TILED_DISPLAY',
+            ':description' => 'Bevorzugt ein Nutzer eine Kachelansicht auf der Werkzeugseite in den Veranstaltungen oder lieber eine Tabelle?',
+            ':range'       => 'user',
+            ':type'        => 'boolean',
+            ':value'       => '1'
+        ]);
+    }
+
+    protected function down()
+    {
+        $query = "ALTER TABLE `plugins`
+                  DROP COLUMN `description`,
+                  DROP COLUMN `highlight_until`,
+                  DROP COLUMN `highlight_text`";
+        DBManager::get()->exec($query);
+
+        $query = "DELETE FROM `config_values`
+                  WHERE `field` = 'CONTENTMODULES_TILED_DISPLAY' ";
+        DBManager::get()->exec($query);
+        $query = "DELETE FROM `config`
+                  WHERE `field` = 'CONTENTMODULES_TILED_DISPLAY' ";
+        DBManager::get()->exec($query);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php b/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php
index c4fda2f33a1..c45f3a4aa92 100644
--- a/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php
+++ b/lib/classes/JsonApi/Routes/ConfigValues/ConfigValuesUpdate.php
@@ -33,6 +33,7 @@ class ConfigValuesUpdate extends JsonApiController
         // TODO: zunächst kann diese Route nur Konfigurationseinstellungen vom Typ bool ändern
         if (
             'boolean' !== $resource->entry['type']
+            && $resource->entry['field'] !== 'CONTENTMODULES_TILED_DISPLAY'
             && $resource->entry['field'] !== 'MY_COURSES_OPEN_GROUPS'
             && $resource->entry['field'] !== 'MY_COURSES_VIEW_SETTINGS'
         ) {
diff --git a/lib/classes/JsonApi/Schemas/Course.php b/lib/classes/JsonApi/Schemas/Course.php
index acc302ec3d9..09f11754e40 100644
--- a/lib/classes/JsonApi/Schemas/Course.php
+++ b/lib/classes/JsonApi/Schemas/Course.php
@@ -27,6 +27,7 @@ class Course extends SchemaProvider
     const REL_START_SEMESTER = 'start-semester';
     const REL_STATUS_GROUPS = 'status-groups';
     const REL_WIKI_PAGES = 'wiki-pages';
+    const REL_TOOLS = 'tools';
 
     public function getId($course): ?string
     {
@@ -82,6 +83,7 @@ class Course extends SchemaProvider
         $relationships = $this->getSemTypeRelationship($relationships, $course, $includeList);
         $relationships = $this->getStatusGroupsRelationship($relationships, $course, $includeList);
         $relationships = $this->getWikiPagesRelationship($relationships, $course, $includeList);
+        $relationships = $this->getToolsRelationship($relationships, $course, $includeList);
 
         return $relationships;
     }
@@ -298,6 +300,26 @@ class Course extends SchemaProvider
         return $relationships;
     }
 
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    private function getToolsRelationship(
+        array $relationships,
+        \Course $course,
+        $includeData
+    ) {
+        $relation = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($course, self::REL_TOOLS),
+            ]
+        ];
+        if (in_array(self::REL_TOOLS, $includeData)) {
+            $relation[self::RELATIONSHIP_DATA] = $course->tools->getArrayCopy();
+        }
+
+        return array_merge($relationships, [self::REL_TOOLS => $relation]);
+    }
+
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
diff --git a/lib/classes/SemClass.class.php b/lib/classes/SemClass.class.php
index 36bfd70d631..0fd486870df 100644
--- a/lib/classes/SemClass.class.php
+++ b/lib/classes/SemClass.class.php
@@ -71,10 +71,10 @@ class SemClass implements ArrayAccess
         $type = isset($INST_MODULES[$type]) ? $type : 'default';
 
         $data = [
-            'name'                => 'Generierte Standardinstitutsklasse',
+            'name'                => _('Generierte Standardinstitutsklasse'),
             'visible'             => 1,
-            'overview'            => 'CoreOverview', // always available
-            'admin'               => 'CoreAdmin'     // always available
+            'admin'               => 'CoreAdmin',     // always available
+            'overview'            => 'CoreOverview'   // always available
         ];
         $slots = [
             'forum'               => 'CoreForum',
@@ -86,8 +86,8 @@ class SemClass implements ArrayAccess
             'personal'            => 'CorePersonal'
         ];
         $modules = [
+            'CoreAdmin'           => ['activated' => 1, 'sticky' => 1],
             'CoreOverview'        => ['activated' => 1, 'sticky' => 1],
-            'CoreAdmin'           => ['activated' => 1, 'sticky' => 1]
         ];
 
         foreach ($slots as $slot => $module) {
diff --git a/lib/classes/forms/DatetimepickerInput.php b/lib/classes/forms/DatetimepickerInput.php
index 9bee82bb344..060946f6877 100644
--- a/lib/classes/forms/DatetimepickerInput.php
+++ b/lib/classes/forms/DatetimepickerInput.php
@@ -22,4 +22,13 @@ class DatetimepickerInput extends Input
         $template->attributes = $attributes;
         return $template->render();
     }
+
+    /**
+     * Turns an empty string into null value.
+     * @return integer|null
+     */
+    public function dataMapper($value)
+    {
+        return $value ?: null;
+    }
 }
diff --git a/lib/classes/forms/Form.php b/lib/classes/forms/Form.php
index 936f324b40c..892e144533f 100644
--- a/lib/classes/forms/Form.php
+++ b/lib/classes/forms/Form.php
@@ -18,6 +18,7 @@ class Form extends Part
     protected $save_button_name = '';
 
     protected $autoStore = false;
+    protected $debugmode = false;
     protected $success_message = '';
 
     protected $collapsable = false;
@@ -211,6 +212,17 @@ class Form extends Part
         return $this;
     }
 
+    public function setDebugMode(bool $debug = true): Form
+    {
+        $this->debugmode = $debug;
+        return $this;
+    }
+
+    public function getDebugMode(): bool
+    {
+        return $this->debugmode;
+    }
+
     public function getSuccessMessage() : string
     {
         return $this->success_message;
@@ -301,11 +313,9 @@ class Form extends Part
         $all_values = [];
         foreach ($this->getAllInputs() as $input) {
             $value = $this->getStorableValueFromRequest($input);
-            if ($value !== null) {
-                $callback = $this->getStoringCallback($input);
-                if (is_callable($callback)) {
-                    $stored += $callback($value, $input);
-                }
+            $callback = $this->getStoringCallback($input);
+            if (is_callable($callback)) {
+                $stored += $callback($value, $input);
                 $all_values[$input->getName()] = $value;
             }
         }
@@ -388,7 +398,11 @@ class Form extends Part
             return $input->store;
         }
         $context = $input->getParent()->getContextObject();
-        if ($context && is_subclass_of($context, \SimpleORMap::class)) {
+        if (
+            $context
+            && is_subclass_of($context, \SimpleORMap::class)
+            && $context->isField($input->getName())
+        ) {
             return function ($value) use ($context, $input) {
                 $context[$input->getName()] = $value;
             };
diff --git a/lib/classes/forms/InfoInput.php b/lib/classes/forms/InfoInput.php
new file mode 100644
index 00000000000..561feeedd9e
--- /dev/null
+++ b/lib/classes/forms/InfoInput.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Studip\Forms;
+
+class InfoInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/info_input');
+        $template->title = $this->title;
+        $template->value = $this->value;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+
+    public function getAllInputNames()
+    {
+        return [];
+    }
+}
diff --git a/lib/classes/forms/Part.php b/lib/classes/forms/Part.php
index 3609eb4fad3..1d1c9d0901f 100644
--- a/lib/classes/forms/Part.php
+++ b/lib/classes/forms/Part.php
@@ -67,13 +67,13 @@ abstract class Part
     /**
      * Adds an Input to this Part.
      * @param Input $input
-     * @return Input
+     * @return $this
      */
     public function addInput(Input $input)
     {
         $input->setParent($this);
         $this->parts[] = $input;
-        return $input;
+        return $this;
     }
 
     /**
@@ -81,15 +81,15 @@ abstract class Part
      *
      * @param string $text The text to be added.
      * @param bool $text_is_html Whether the text is HTML (true) or plain text (false). Defaults to true.
-     * @return Text The added text form part.
+     * @return $this
      */
-    public function addText(string $text, bool $text_is_html = true): Text
+    public function addText(string $text, bool $text_is_html = true)
     {
         $text_part = new Text();
         $text_part->setText($text, $text_is_html);
         $text_part->setParent($this);
         $this->parts[] = $text_part;
-        return $text_part;
+        return $this;
     }
 
     /**
@@ -100,16 +100,16 @@ abstract class Part
      * @param \Icon|null $icon The icon to be used for the link.
      * @param array $attributes Additional link attributes.
      *
-     * @return Link The Text form element containing the link as HTML.
+     * @return $this
      */
-    public function addLink(string $title, string $url, ?\Icon $icon = null, array $attributes = []): Link
+    public function addLink(string $title, string $url, ?\Icon $icon = null, array $attributes = [])
     {
         $link = new Link($url, $title, $icon);
         $link->setAttributes($attributes);
 
         $this->addPart($link);
 
-        return $link;
+        return $this;
     }
 
     /**
diff --git a/lib/classes/forms/WysiwygInput.php b/lib/classes/forms/WysiwygInput.php
new file mode 100644
index 00000000000..f2702261129
--- /dev/null
+++ b/lib/classes/forms/WysiwygInput.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Studip\Forms;
+
+class WysiwygInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/wysiwyg_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+
+    public function getRequestValue()
+    {
+        $value = \Request::get($this->name);
+        if (trim($value)) {
+            return \Studip\Markup::markAsHtml(
+                \Studip\Markup::purifyHtml($value)
+            );
+        } else {
+            return '';
+        }
+    }
+}
diff --git a/lib/classes/sidebar/OptionsWidget.php b/lib/classes/sidebar/OptionsWidget.php
index a7931ce5785..1e56ed1cd76 100644
--- a/lib/classes/sidebar/OptionsWidget.php
+++ b/lib/classes/sidebar/OptionsWidget.php
@@ -55,8 +55,9 @@ class OptionsWidget extends ListWidget
         $url = html_entity_decode($url);
 
         $content = sprintf(
-            '<a href="%s" class="options-radio options-%s" %s>%s</a>',
+            '<a href="%s" role="radio" aria-checked="%s" class="options-radio options-%s" %s>%s</a>',
             htmlReady($url),
+            $checked ? 'true' : 'false',
             $checked ? 'checked' : 'unchecked',
             arrayToHtmlAttributes($attributes),
             htmlReady($label)
diff --git a/lib/models/Plugin.php b/lib/models/Plugin.php
new file mode 100644
index 00000000000..2030e8a3c82
--- /dev/null
+++ b/lib/models/Plugin.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * @property int $id
+ * @property int $pluginid
+ * @property string $pluginclassname
+ * @property string $pluginpath
+ * @property string $pluginname
+ * @property string $plugintype
+ * @property string $enabled
+ * @property int $navigationpos
+ * @property int|null $dependentonid
+ * @property string|null $automatic_update_url
+ * @property string|null $automatic_update_secret
+ */
+class Plugin extends SimpleORMap
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'plugins';
+        $config['i18n_fields'] = ['description', 'highlight_text'];
+        parent::configure($config);
+    }
+
+}
diff --git a/lib/models/ToolActivation.php b/lib/models/ToolActivation.php
index 422fb0f991d..f3998780656 100644
--- a/lib/models/ToolActivation.php
+++ b/lib/models/ToolActivation.php
@@ -37,6 +37,7 @@ class ToolActivation extends SimpleORMap
         ];
 
         $config['serialized_fields']['metadata'] = 'JSONArrayObject';
+
         $config['registered_callbacks']['before_create'][] = 'setMaxPosition';
 
         parent::configure($config);
@@ -94,5 +95,4 @@ class ToolActivation extends SimpleORMap
             return 'nobody';
         }
     }
-
 }
diff --git a/lib/modules/Blubber.class.php b/lib/modules/Blubber.class.php
index 7b1dffec66a..4dd2f998e29 100644
--- a/lib/modules/Blubber.class.php
+++ b/lib/modules/Blubber.class.php
@@ -117,12 +117,14 @@ class Blubber extends CorePlugin implements StudipModule
     public function getMetadata()
     {
         return [
-            'summary' => _('Schneller und einfacher Austausch von Informationen in Gesprächsform'),
+            'displayname' => _('Blubber'),
+            'summary' => _('Schneller Austausch von Informationen in Gesprächsform'),
             'description' => _('Blubber ist eine Kommunikationsform mit Ähnlichkeiten zu einem Forum, in dem aber in Echtzeit miteinander kommuniziert werden kann und das durch den etwas informelleren Charakter eher einem Chat anmutet. Anders als im Forum ist es nicht notwendig, die Seiten neu zu laden, um die neuesten Einträge (z. B. Antworten auf eigene Postings) sehen zu können: Die Seite aktualisiert sich selbst bei neuen Einträgen. Dateien (z.B. Fotos, Audiodateien, Links) können per Drag and Drop in das Feld gezogen und somit verlinkt werden. Auch Textformatierungen sind möglich.'),
             'descriptionlong' => _('Kommunikationsform mit Ähnlichkeiten zu einem Forum. Im Gegensatz zum Forum kann mit Blubber jedoch in Echtzeit miteinander kommuniziert werden. Das Tool ähnelt durch den etwas informelleren Charakter einem Messenger. Anders als im Forum ist es nicht notwendig, die Seiten neu zu laden, um die neuesten Einträge (z. B. Antworten auf eigene Postings) sehen zu können. Dateien (z. B. Fotos, Audiodateien, Links) können per drag and drop in das Feld gezogen und somit verlinkt werden. Auch Textformatierungen sind möglich.'),
             'category' => _('Kommunikation und Zusammenarbeit'),
             'keywords' => _('Einfach Text schreiben und mit <Enter> abschicken; Direktes Kontaktieren anderer Stud.IP-NutzerInnen (@Vorname Nachname); Setzen von und Suche nach Stichworten über Hashtags (#Stichwort); Einbinden von Dateien per drag and drop'),
             'icon' => Icon::create('blubber', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('blubber', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Blubber',
                 'pictures' => [
diff --git a/lib/modules/ConsultationModule.class.php b/lib/modules/ConsultationModule.class.php
index 56717766df2..c68f8ca2d58 100644
--- a/lib/modules/ConsultationModule.class.php
+++ b/lib/modules/ConsultationModule.class.php
@@ -143,6 +143,7 @@ class ConsultationModule extends CorePlugin implements StudipModule, SystemPlugi
             'keywords'    => _('Terminvergabe, Sprechstunden'),
             'displayname' => _('Terminvergabe'),
             'icon'        => Icon::create('consultation', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('consultation', Icon::ROLE_CLICKABLE),
              'screenshots' => [
                  'path'     => 'assets/images/plus/screenshots/Terminvergabe',
                  'pictures' => [
diff --git a/lib/modules/CoreAdmin.class.php b/lib/modules/CoreAdmin.class.php
index ada2af02aa2..948809cf514 100644
--- a/lib/modules/CoreAdmin.class.php
+++ b/lib/modules/CoreAdmin.class.php
@@ -24,15 +24,13 @@ class CoreAdmin extends CorePlugin implements StudipModule
      */
     public function getTabNavigation($course_id)
     {
-        $sem_create_perm = in_array(Config::get()->SEM_CREATE_PERM, ['root','admin','dozent']) ? Config::get()->SEM_CREATE_PERM : 'dozent';
-
         if ($GLOBALS['perm']->have_studip_perm('tutor', $course_id)) {
             $navigation = new Navigation(_('Verwaltung'));
             $navigation->setImage(Icon::create('admin', Icon::ROLE_INFO_ALT));
             $navigation->setActiveImage(Icon::create('admin', Icon::ROLE_INFO));
 
-            $main = new Navigation(_('Verwaltung'), 'dispatch.php/course/management');
-            $navigation->addSubNavigation('main', $main);
+            $main = new Navigation(_('Werkzeuge'), 'dispatch.php/course/contentmodules');
+            $navigation->addSubNavigation('contentmodules', $main);
 
             if (!Context::isInstitute()) {
                 $item = new Navigation(_('Grunddaten'), 'dispatch.php/course/basicdata/view/' . $course_id);
diff --git a/lib/modules/CoreCalendar.class.php b/lib/modules/CoreCalendar.class.php
index 78d4b876e20..c0df36736d7 100644
--- a/lib/modules/CoreCalendar.class.php
+++ b/lib/modules/CoreCalendar.class.php
@@ -49,6 +49,7 @@ class CoreCalendar extends CorePlugin implements StudipModule
             'summary' => _('Kalender'),
             'category' => _('Lehr- und Lernorganisation'),
             'icon' => Icon::create('schedule', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('schedule', Icon::ROLE_CLICKABLE),
             'displayname' => _('Planer'),
         ];
     }
diff --git a/lib/modules/CoreDocuments.class.php b/lib/modules/CoreDocuments.class.php
index 4543b8773de..2acfeef2275 100644
--- a/lib/modules/CoreDocuments.class.php
+++ b/lib/modules/CoreDocuments.class.php
@@ -154,7 +154,7 @@ class CoreDocuments extends CorePlugin implements StudipModule, OERModule
     public function getMetadata()
     {
         return [
-            'summary'          => _('Austausch von Dateien'),
+            'summary'          => _('Austausch von Dateien, Hausaufgabenordner & Terminordner'),
             'description'      => _('Im Dateibereich können Dateien sowohl von ' .
                 'Lehrenden als auch von Studierenden hoch- bzw. ' .
                 'heruntergeladen werden. Es können Ordner angelegt und ' .
@@ -183,6 +183,7 @@ class CoreDocuments extends CorePlugin implements StudipModule, OERModule
                 'können Im Dateibereich bestimmte Rechte (r, w, x, f) für Studierende, wie z.B. das ' .
                 'Leserecht (r), festgelegt werden.'),
             'icon'             => Icon::create('files', Icon::ROLE_INFO),
+            'icon_clickable'   => Icon::create('files', Icon::ROLE_CLICKABLE),
             'screenshots'      => [
                 'path'     => 'assets/images/plus/screenshots/Dateibereich_-_Dateiordnerberechtigung',
                 'pictures' => [
diff --git a/lib/modules/CoreElearningInterface.class.php b/lib/modules/CoreElearningInterface.class.php
index 7e36a1b7243..a5f71196a0c 100644
--- a/lib/modules/CoreElearningInterface.class.php
+++ b/lib/modules/CoreElearningInterface.class.php
@@ -120,6 +120,7 @@ class CoreElearningInterface extends CorePlugin implements StudipModule
                             Zugang zu externen Lernplattformen;
                             Aufgaben- und Test-Erstellung'),
             'icon' => Icon::create('learnmodule', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('learnmodule', Icon::ROLE_CLICKABLE),
             'descriptionshort' => _('Zugang zu extern erstellten Lernmodulen'),
             'descriptionlong' => _('Über diese Schnittstelle ist es möglich, Selbstlerneinheiten, '.
                                     'die in externen Programmen erstellt werden, in Stud.IP zur Verfügung '.
diff --git a/lib/modules/CoreForum.class.php b/lib/modules/CoreForum.class.php
index ba1ee64f7e2..3a43372c204 100644
--- a/lib/modules/CoreForum.class.php
+++ b/lib/modules/CoreForum.class.php
@@ -196,6 +196,7 @@ class CoreForum extends CorePlugin implements ForumModule
             'category' => _('Kommunikation und Zusammenarbeit'),
             'keywords' => _('Möglichkeit zum intensiven, nachhaltigen textbasierten Austausch; (nachträgliche) Strukturierung der Beiträge; Editierfunktion für Lehrende'),
             'icon' => Icon::create('forum', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('forum', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Forum',
                 'pictures' => [
diff --git a/lib/modules/CoreOverview.class.php b/lib/modules/CoreOverview.class.php
index 39145aea83b..af1b9604774 100644
--- a/lib/modules/CoreOverview.class.php
+++ b/lib/modules/CoreOverview.class.php
@@ -110,7 +110,10 @@ class CoreOverview extends CorePlugin implements StudipModule
     public function getMetadata()
     {
         return [
-            'displayname' => _('Ãœbersicht')
+            'displayname' => _('Ãœbersicht'),
+            'summary' => _('Ankündigungen, Termine, Fragebögen & Details'),
+            'icon' => Icon::create('home', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('home', Icon::ROLE_CLICKABLE)
         ];
     }
 
diff --git a/lib/modules/CoreParticipants.class.php b/lib/modules/CoreParticipants.class.php
index a9fac5c2a42..14c885b816b 100644
--- a/lib/modules/CoreParticipants.class.php
+++ b/lib/modules/CoreParticipants.class.php
@@ -178,6 +178,7 @@ class CoreParticipants extends CorePlugin implements StudipModule
                                    'bzw. einzelne Teilnehmende separat anzuschreiben.'),
             'category' => _('Lehr- und Lernorganisation'),
             'icon' => Icon::create('persons', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('persons', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/TeilnehmerInnen',
                 'pictures' => [
diff --git a/lib/modules/CorePersonal.class.php b/lib/modules/CorePersonal.class.php
index 1f99d7f5c2a..71aaa6b728f 100644
--- a/lib/modules/CorePersonal.class.php
+++ b/lib/modules/CorePersonal.class.php
@@ -49,6 +49,7 @@ class CorePersonal extends CorePlugin implements StudipModule
             'displayname'      => _('MitarbeiterInnen'),
             'category'         => _('Sonstiges'),
             'icon'             => Icon::create('persons', Icon::ROLE_INFO),
+            'icon_clickable'   => Icon::create('persons', Icon::ROLE_CLICKABLE)
         ];
     }
 
diff --git a/lib/modules/CoreSchedule.class.php b/lib/modules/CoreSchedule.class.php
index 14fab1d1d7c..601b618104a 100644
--- a/lib/modules/CoreSchedule.class.php
+++ b/lib/modules/CoreSchedule.class.php
@@ -106,6 +106,7 @@ class CoreSchedule extends CorePlugin implements StudipModule
                                     'inhaltlichen Einstimmung der Studierenden können Lehrende den Terminen ' .
                                     'Themen hinzufügen, die z. B. eine Kurzbeschreibung der Inhalte darstellen.'),
             'icon' => Icon::create('schedule', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('schedule', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Ablaufplan',
                 'pictures' => [
diff --git a/lib/modules/CoreScm.class.php b/lib/modules/CoreScm.class.php
index 86e10e14d70..d37023b96e6 100644
--- a/lib/modules/CoreScm.class.php
+++ b/lib/modules/CoreScm.class.php
@@ -139,6 +139,7 @@ class CoreScm extends CorePlugin implements StudipModule
                                     'Literatur. Sie kann aber auch für andere beliebige Zusatzinformationen (Links, Protokolle '.
                                     'etc.) verwendet werden.'),
             'icon' => Icon::create('infopage', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('infopage', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Freie_Informationsseite',
                 'pictures' => [
diff --git a/lib/modules/CoreStudygroupAdmin.class.php b/lib/modules/CoreStudygroupAdmin.class.php
index 7c67a26f1c4..d31139066f6 100644
--- a/lib/modules/CoreStudygroupAdmin.class.php
+++ b/lib/modules/CoreStudygroupAdmin.class.php
@@ -35,6 +35,7 @@ class CoreStudygroupAdmin extends CorePlugin implements StudipModule
         $navigation->setImage(Icon::create('admin', Icon::ROLE_INFO_ALT));
         $navigation->setActiveImage(Icon::create('admin', Icon::ROLE_INFO));
 
+        $navigation->addSubNavigation('contentmodules', new Navigation(_('Werkzeuge'), "dispatch.php/course/contentmodules?cid={$course_id}"));
         $navigation->addSubNavigation('main', new Navigation(_('Verwaltung'), "dispatch.php/course/studygroup/edit/?cid={$course_id}"));
         $navigation->addSubNavigation('avatar', new Navigation(_('Infobild'), "dispatch.php/avatar/update/course/{$course_id}?cid={$course_id}"));
 
diff --git a/lib/modules/CoreWiki.class.php b/lib/modules/CoreWiki.class.php
index 0034098eef4..4700334d166 100644
--- a/lib/modules/CoreWiki.class.php
+++ b/lib/modules/CoreWiki.class.php
@@ -119,7 +119,7 @@ class CoreWiki extends CorePlugin implements StudipModule
     public function getMetadata()
     {
         return [
-            'summary' => _('Gemeinsames asynchrones Erstellen und Bearbeiten von Texten'),
+            'summary' => _('Gemeinsames Erstellen und Bearbeiten von Texten'),
             'description' => _('Im Wiki-Web oder kurz "Wiki" können '.
                 'verschiedene Autor/-innen gemeinsam Texte, Konzepte und andere '.
                 'schriftliche Arbeiten erstellen und gestalten, dies '.
@@ -151,6 +151,7 @@ class CoreWiki extends CorePlugin implements StudipModule
                                     'PDF-Datei ist integriert.'),
             'category' => _('Kommunikation und Zusammenarbeit'),
             'icon' => Icon::create('wiki', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('wiki', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Wiki-Web',
                 'pictures' => [
diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php
index d085de22eb6..9de221f0c22 100644
--- a/lib/modules/CoursewareModule.class.php
+++ b/lib/modules/CoursewareModule.class.php
@@ -81,11 +81,11 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule
     public function getIconNavigation($courseId, $last_visit, $user_id)
     {
         $statement = DBManager::get()->prepare("
-                SELECT COUNT(DISTINCT elem.id) 
-                FROM `cw_structural_elements` AS elem 
+                SELECT COUNT(DISTINCT elem.id)
+                FROM `cw_structural_elements` AS elem
                 INNER JOIN `cw_containers` as container ON (elem.id = container.structural_element_id)
                 INNER JOIN `cw_blocks` as blocks ON (container.id = blocks.container_id)
-                WHERE elem.range_type = 'course' 
+                WHERE elem.range_type = 'course'
                 AND elem.range_id = :range_id
                 AND blocks.payload != ''
                 AND blocks.chdate > :last_visit
@@ -141,6 +141,7 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule
             'displayname' => _('Courseware'),
             'category' => _('Lehr- und Lernorganisation'),
             'icon' => Icon::create('courseware', 'info'),
+            'icon_clickable' => Icon::create('courseware', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Courseware',
                 'pictures' => [
diff --git a/lib/modules/FeedbackModule.class.php b/lib/modules/FeedbackModule.class.php
index c369ea72a31..8674f62687b 100644
--- a/lib/modules/FeedbackModule.class.php
+++ b/lib/modules/FeedbackModule.class.php
@@ -53,6 +53,7 @@ class FeedbackModule extends CorePlugin implements StudipModule, SystemPlugin
             'category'      => _('Kommunikation und Zusammenarbeit'),
             'keywords'      => _('Anlegen von Feedback-Elementen an verschiedenen Stellen; Auswahl verschiedener Feedback-Modi, wie Sternbewertung; Übersicht über alle Feedback-Elemente einer Veranstaltung'),
             'icon'          => Icon::create('star', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('star', Icon::ROLE_CLICKABLE),
             'screenshots'   => [
                 'path'      => 'assets/images/plus/screenshots/Feedback',
                 'pictures'      => [
diff --git a/lib/modules/GradebookModule.class.php b/lib/modules/GradebookModule.class.php
index ac8d69b2a4a..f459f106c4e 100644
--- a/lib/modules/GradebookModule.class.php
+++ b/lib/modules/GradebookModule.class.php
@@ -147,6 +147,7 @@ class GradebookModule extends CorePlugin implements SystemPlugin, StudipModule
             'category' => _('Lehr- und Lernorganisation'),
             'keywords' => _('automatische und manuelle Erfassung von gewichteten Leistungen;Export von Leistungen;persönliche Fortschrittskontrolle'),
             'icon' => Icon::create('assessment', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('assessment', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Gradebook',
                 'pictures' => [
diff --git a/lib/modules/IliasInterfaceModule.class.php b/lib/modules/IliasInterfaceModule.class.php
index f5bc83f855d..a8cafbf24d9 100644
--- a/lib/modules/IliasInterfaceModule.class.php
+++ b/lib/modules/IliasInterfaceModule.class.php
@@ -153,6 +153,7 @@ class IliasInterfaceModule extends CorePlugin implements StudipModule, SystemPlu
                             Zugang zu ILIAS;
                             Aufgaben- und Test-Erstellung'),
             'icon'             => Icon::create('learnmodule', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('learnmodule', Icon::ROLE_CLICKABLE),
             'descriptionshort' => _('Zugang zu extern erstellten ILIAS-Lernobjekten'),
             'descriptionlong'  => _('Über diese Schnittstelle ist es möglich, Lernobjekte aus ' .
                 'einer ILIAS-Installation (> 5.3.8) in Stud.IP zur Verfügung ' .
diff --git a/lib/modules/LtiToolModule.class.php b/lib/modules/LtiToolModule.class.php
index 0383f115f64..eac2768184b 100644
--- a/lib/modules/LtiToolModule.class.php
+++ b/lib/modules/LtiToolModule.class.php
@@ -108,6 +108,7 @@ class LtiToolModule extends CorePlugin implements StudipModule, SystemPlugin, Pr
             'category' => _('Kommunikation und Zusammenarbeit'),
             'keywords' => _('Einbindung von LTI-Tools (Version 1.x)'),
             'icon' => Icon::create('link-extern', Icon::ROLE_INFO),
+            'icon_clickable' => Icon::create('link-extern', Icon::ROLE_CLICKABLE),
             'screenshots' => [
                 'path' => 'assets/images/plus/screenshots/Lti',
                 'pictures' => [
diff --git a/lib/navigation/CourseNavigation.php b/lib/navigation/CourseNavigation.php
index 2ff62ad8058..6e37cf58932 100644
--- a/lib/navigation/CourseNavigation.php
+++ b/lib/navigation/CourseNavigation.php
@@ -53,7 +53,26 @@ class CourseNavigation extends Navigation
             return;
         }
 
-        foreach ($context->tools as $tool) {
+        $admin_plugin_ids = [];
+        $core_admin = PluginManager::getInstance()->getPlugin('CoreAdmin');
+        if ($core_admin) {
+            $admin_plugin_ids[] = $core_admin->getPluginId();
+        }
+        $core_studygroup_admin = PluginManager::getInstance()->getPlugin('CoreStudygroupAdmin');
+        if ($core_studygroup_admin) {
+            $admin_plugin_ids[] = $core_studygroup_admin->getPluginId();
+        }
+        $tools = $context->tools->getArrayCopy();
+        usort($tools, function ($a, $b) use ($admin_plugin_ids) {
+            if (in_array($a['plugin_id'], $admin_plugin_ids)) {
+                return -1;
+            }
+            if (in_array($b['plugin_id'], $admin_plugin_ids)) {
+                return 1;
+            }
+            return $a['position'] - $b['position'];
+        });
+        foreach ($tools as $tool) {
             if (Context::isInstitute() || Seminar_Perm::get()->have_studip_perm($tool->getVisibilityPermission(), $context->id)) {
                 $studip_module = $tool->getStudipModule();
                 if ($studip_module instanceof StudipModule) {
diff --git a/lib/plugins/core/CorePlugin.php b/lib/plugins/core/CorePlugin.php
index e16b77a8e37..0bba2897b13 100644
--- a/lib/plugins/core/CorePlugin.php
+++ b/lib/plugins/core/CorePlugin.php
@@ -60,6 +60,41 @@ abstract class CorePlugin
         return '';
     }
 
+    public function getPluginDescription()
+    {
+        $metadata = $this->getMetadata();
+        $language = getUserLanguage(User::findCurrent()->id);
+        if ($metadata['descriptionlong_' . $language]) {
+            return $metadata['descriptionlong_' . $language];
+        }
+        if ($metadata['description_' . $language]) {
+            return $metadata['description_' . $language];
+        }
+        $description = $metadata['descriptionlong'] ?? $metadata['description'];
+
+        if ($this->plugin_info['description_mode'] === 'override_description') {
+            return $this->plugin_info['description'];
+        } else {
+            return '<!-- HTML --><div>' . $description . '</div>' . $this->plugin_info['description'];
+        }
+    }
+
+    public function getDescriptionMode()
+    {
+        return $this->plugin_info['description_mode'];
+    }
+
+    public function isHighlighted()
+    {
+        return $this->plugin_info['highlight_until'] > time();
+    }
+
+    public function getHighlightText()
+    {
+        return $this->plugin_info['highlight_text'];
+    }
+
+
     /**
      * Checks if the plugin is a core-plugin. Returns true if this is the case.
      *
diff --git a/lib/plugins/core/StudIPPlugin.class.php b/lib/plugins/core/StudIPPlugin.class.php
index fafd583f68c..a3dacb6b8b0 100644
--- a/lib/plugins/core/StudIPPlugin.class.php
+++ b/lib/plugins/core/StudIPPlugin.class.php
@@ -80,6 +80,45 @@ abstract class StudIPPlugin
         return $this->manifest;
     }
 
+    /**
+     * Returns the description of the plugin either from plugins-table or from the manifest's descriptionlong
+     * or description attribute.
+     * @return string|null
+     */
+    public function getPluginDescription()
+    {
+        $metadata = $this->getMetadata();
+        $language = getUserLanguage(User::findCurrent()->id);
+        if ($metadata['descriptionlong_' . $language]) {
+            return $metadata['descriptionlong_' . $language];
+        }
+        if ($metadata['description_' . $language]) {
+            return $metadata['description_' . $language];
+        }
+        $description = $metadata['descriptionlong'] ?? $metadata['description'];
+
+        if ($this->plugin_info['description_mode'] === 'override_description') {
+            return $this->plugin_info['description'];
+        } else {
+            return $description . $this->plugin_info['description'];
+        }
+    }
+
+    public function getDescriptionMode()
+    {
+        return $this->plugin_info['description_mode'];
+    }
+
+    public function isHighlighted()
+    {
+        return $this->plugin_info['highlight_until'] > time();
+    }
+
+    public function getHighlightText()
+    {
+        return $this->plugin_info['highlight_text'];
+    }
+
     /**
      * Returns the version of this plugin as defined in manifest.
      * @return string
diff --git a/lib/plugins/engine/PluginManager.class.php b/lib/plugins/engine/PluginManager.class.php
index 86305a188c7..7fb69bc8e3f 100644
--- a/lib/plugins/engine/PluginManager.class.php
+++ b/lib/plugins/engine/PluginManager.class.php
@@ -85,7 +85,11 @@ class PluginManager
                 'depends'                 => (int) $plugin['dependentonid'],
                 'core'                    => $plugin['pluginpath'] === '',
                 'automatic_update_url'    => $plugin['automatic_update_url'],
-                'automatic_update_secret' => $plugin['automatic_update_secret']
+                'automatic_update_secret' => $plugin['automatic_update_secret'],
+                'description'             => $plugin['description'],
+                'description_mode'         => $plugin['description_mode'],
+                'highlight_until'         => $plugin['highlight_until'],
+                'highlight_text'          => $plugin['highlight_text']
             ];
         }
     }
@@ -251,11 +255,16 @@ class PluginManager
             $activation->range_type = $range->getRangeType();
         }
         $plugin = $this->getPluginById($id);
+
         if ($active) {
             call_user_func([get_class($plugin), 'onActivation'], $id, $rangeId);
+            StudipLog::log('PLUGIN_ENABLE', $rangeId, $id, User::findCurrent()->id);
+            NotificationCenter::postNotification('PluginDidActivate', $rangeId, $id);
             return $activation->store();
         } else {
             call_user_func([get_class($plugin), 'onDeactivation'], $id, $rangeId);
+            StudipLog::log('PLUGIN_DISABLE', $rangeId, $id, User::findCurrent()->id);
+            NotificationCenter::postNotification('PluginDidDeactivate', $rangeId, $id);
             return $activation->delete();
         }
     }
diff --git a/lib/seminar_open.php b/lib/seminar_open.php
index a381d58d1d1..33436d490d2 100644
--- a/lib/seminar_open.php
+++ b/lib/seminar_open.php
@@ -143,19 +143,6 @@ if (Request::int('disable_plugins') !== null && ($user->id === 'nobody' || $perm
 // load the default set of plugins
 PluginEngine::loadPlugins();
 
-// add navigation item: add modules
-if (Context::isCourse() && $perm->have_studip_perm('tutor', Context::getId())) {
-    $plus_nav = new Navigation(_('Mehr …'), 'dispatch.php/course/plus/index');
-    $plus_nav->setDescription(_("Mehr Stud.IP-Funktionen für Ihre Veranstaltung"));
-    Navigation::addItem('/course/modules', $plus_nav);
-}
-
-// add navigation item: add modules (institute)
-if (Context::isInstitute() && $perm->have_studip_perm('admin', Context::getId())) {
-    $plus_nav = new Navigation(_('Mehr …'), 'dispatch.php/course/plus/index');
-    $plus_nav->setDescription(_("Mehr Stud.IP-Funktionen für Ihre Einrichtung"));
-    Navigation::addItem('/course/modules', $plus_nav);
-}
 // add navigation item for profile: add modules
 if (Navigation::hasItem('/profile/edit')) {
     $plus_nav = new Navigation(_('Mehr …'), 'dispatch.php/profilemodules/index');
diff --git a/resources/assets/javascripts/bootstrap/contentmodules.js b/resources/assets/javascripts/bootstrap/contentmodules.js
new file mode 100644
index 00000000000..3d3f886ff0c
--- /dev/null
+++ b/resources/assets/javascripts/bootstrap/contentmodules.js
@@ -0,0 +1,40 @@
+STUDIP.domReady(() => {
+    const node = document.querySelector('.content-modules-vue-app');
+    if (!node) {
+        return;
+    }
+
+    Promise.all([
+        STUDIP.Vue.load(),
+        import('../../../vue/store/ContentModulesStore.js').then((config) => config.default),
+        import('../../../vue/components/ContentModules.vue').then((component) => component.default),
+    ]).then(([{ createApp, store }, storeConfig, ContentModules]) => {
+        store.registerModule('contentmodules', storeConfig);
+
+        Object.entries(window.ContentModulesStoreData ?? {}).forEach(([key, value]) => {
+            store.commit(`contentmodules/${key}`, value);
+        });
+
+        const vm = createApp({
+            components: { ContentModules }
+        });
+        vm.$mount(node);
+    });
+});
+
+STUDIP.dialogReady(() => {
+    const node = document.querySelector('.content-modules-controls-vue-app');
+    if (!node) {
+        return;
+    }
+
+    Promise.all([
+        STUDIP.Vue.load(),
+        import('../../../vue/components/ContentModulesControl.vue').then((component) => component.default),
+    ]).then(([{ createApp }, ContentModulesControl]) => {
+        const vm = createApp({
+            components: { ContentModulesControl }
+        });
+        vm.$mount(node);
+    });
+});
diff --git a/resources/assets/javascripts/bootstrap/forms.js b/resources/assets/javascripts/bootstrap/forms.js
index 3d9be08dc90..1a6bc53e152 100644
--- a/resources/assets/javascripts/bootstrap/forms.js
+++ b/resources/assets/javascripts/bootstrap/forms.js
@@ -251,6 +251,8 @@ STUDIP.ready(function () {
                         params.STUDIPFORM_VALIDATIONNOTES = [];
                         params.STUDIPFORM_AUTOSAVEURL = f.dataset.autosave;
                         params.STUDIPFORM_REDIRECTURL = f.dataset.url;
+                        params.STUDIPFORM_SELECTEDLANGUAGES = {};
+                        params.STUDIPFORM_DEBUGMODE = JSON.parse(f.dataset.debugmode);
                         return params;
                     },
                     methods: {
@@ -278,8 +280,8 @@ STUDIP.ready(function () {
                                     data: params,
                                     type: 'post',
                                     success() {
-                                        if (v.STUDIPFORM_REDIRECTURL) {
-                                            window.location.href = v.STUDIPFORM_REDIRECTURL
+                                        if (v.STUDIPFORM_REDIRECTURL && !v.STUDIPFORM_DEBUGMODE) {
+                                            window.location.href = v.STUDIPFORM_REDIRECTURL;
                                         }
                                     }
                                 });
@@ -336,6 +338,13 @@ STUDIP.ready(function () {
                                     this[key] = value;
                                 }
                             }
+                        },
+                        selectLanguage(input_name, language_id) {
+                            let languages = {
+                                ...this.STUDIPFORM_SELECTEDLANGUAGES
+                            };
+                            languages[input_name] = language_id;
+                            this.STUDIPFORM_SELECTEDLANGUAGES = languages;
                         }
                     },
                     mounted () {
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index 915d960659d..9fcb57ba97d 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -82,6 +82,7 @@ import "./bootstrap/admin-courses.js"
 import "./bootstrap/cache-admin.js"
 import "./bootstrap/oer.js"
 import "./bootstrap/courseware.js"
+import "./bootstrap/contentmodules.js"
 import "./bootstrap/responsive-navigation.js"
 import "./bootstrap/treeview.js"
 import "./bootstrap/stock-images.js"
diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js
index 55276026788..8ea0a27eed6 100644
--- a/resources/assets/javascripts/init.js
+++ b/resources/assets/javascripts/init.js
@@ -54,7 +54,6 @@ import Overlay from './lib/overlay.js';
 import PageLayout from './lib/page_layout.js';
 import parseOptions from './lib/parse_options.js';
 import PersonalNotifications from './lib/personal_notifications.js';
-import Plus from './lib/plus.js';
 import QRCode from './lib/qr_code.js';
 import Questionnaire from './lib/questionnaire.js';
 import QuickSearch from './lib/quick_search.js';
@@ -145,7 +144,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, {
     PageLayout,
     parseOptions,
     PersonalNotifications,
-    Plus,
     QRCode,
     Questionnaire,
     QuickSearch,
diff --git a/resources/assets/javascripts/lib/plus.js b/resources/assets/javascripts/lib/plus.js
deleted file mode 100644
index 0d447fd413f..00000000000
--- a/resources/assets/javascripts/lib/plus.js
+++ /dev/null
@@ -1,23 +0,0 @@
-const Plus = {
-    setModule: function () {
-        $.ajax({
-            "url": STUDIP.URLHelper.getURL("dispatch.php/course/plus/trigger"),
-            "data": {
-                "moduleclass": $(this).data("moduleclass"),
-                "key": $(this).data("key"),
-                "active": $(this).is(":checked") ? 1 : 0
-            },
-            "dataType": "json",
-            "type": "post",
-            "success": function (output) {
-                if (output.tabs) {
-                    $(".tabs_wrapper").replaceWith(output.tabs);
-                }
-            }
-        });
-    }
-};
-
-
-
-export default Plus;
diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss
index d1336153c09..fe376dbbb16 100644
--- a/resources/assets/stylesheets/scss/forms.scss
+++ b/resources/assets/stylesheets/scss/forms.scss
@@ -324,6 +324,7 @@ form.default {
         > * {
             box-sizing: border-box;
             flex: 1 0 auto;
+            max-width: 400px;
 
             &:not(:first-child) {
                 margin-left: 3px;
@@ -596,3 +597,5 @@ form.inline {
         }
     }
 }
+
+
diff --git a/resources/assets/stylesheets/scss/plus.scss b/resources/assets/stylesheets/scss/plus.scss
deleted file mode 100644
index ea0b7b35f14..00000000000
--- a/resources/assets/stylesheets/scss/plus.scss
+++ /dev/null
@@ -1,79 +0,0 @@
-.plus {
-    .element_header {
-        display: inline-block;
-        width: 250px;
-        margin-left: 5px;
-    }
-
-    .element_description {
-        display: inline-block;
-        margin-left: 20px;
-    }
-
-    .plugin_icon {
-        width: 16px;
-        height: 16px;
-    }
-
-    .shortdesc {
-        margin-left: 3px;
-    }
-
-    .plus_expert {
-        margin-left: 20px;
-        width: 97%;
-
-        display: flex;
-        flex-wrap: wrap;
-    }
-
-    .screenshot_holder {
-        width: 250px;
-        flex: 0 250px;
-        margin-right: 5mm;
-        box-sizing: border-box;
-    }
-
-    .big_thumb {
-        max-width: 250px;
-        max-height: 250px;
-        padding-top: 5mm;
-    }
-
-    .small_thumb {
-        margin-left: 2px;
-        margin-top: 5px;
-        max-height: 25px;
-    }
-
-    .thumb_holder {
-        width: 250px;
-        text-align: center;
-        background-color: $content-color-20;
-        border-top: 1px solid mix($brand-color-lighter, $white, 80%);
-        border-bottom: 1px solid mix($brand-color-lighter, $white, 80%);
-    }
-
-    .descriptionbox {
-        flex: 1 305px;
-        max-width: 45em;
-    }
-
-    .keywords {
-        padding: 5mm;
-        left: 5mm;
-        position: relative;
-    }
-
-    .longdesc {
-        overflow: hidden;
-    }
-
-    .helplink {
-        float: right;
-    }
-
-    article.studip > section:not(:last-child) {
-        border-bottom: 1px solid $table-header-color;
-    }
-}
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index be72f0ab70b..edbc2e12fe9 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -70,7 +70,6 @@
 @import "scss/pagination";
 @import "scss/personal-notifications";
 @import "scss/plugins";
-@import "scss/plus";
 @import "scss/progress_indicator.scss";
 @import "scss/profile";
 @import "scss/qrcode";
diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js
index ccff7c5652e..b8cf935e011 100644
--- a/resources/vue/base-components.js
+++ b/resources/vue/base-components.js
@@ -13,6 +13,7 @@ import RangeInput from './components/RangeInput.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';
@@ -36,6 +37,7 @@ const BaseComponents = {
     StudipFolderSize,
     StudipIcon,
     I18nTextarea,
+    StudipWysiwyg,
 //    StudipLoadingIndicator,
     StudipMessageBox,
     StudipProxyCheckbox,
diff --git a/resources/vue/components/ContentModules.vue b/resources/vue/components/ContentModules.vue
new file mode 100644
index 00000000000..0a9d54bee07
--- /dev/null
+++ b/resources/vue/components/ContentModules.vue
@@ -0,0 +1,218 @@
+<template>
+    <form id="module-list" :class="{'table-display': !isTilesDisplay }">
+        <div class="infopanel" v-if="highlighted.length > 0">
+            <div class="top"></div>
+            <div class="navigation_wrapper">
+                <a href="#"
+                   v-if="highlightIndex > 0"
+                   @click.prevent="highlightIndex--"
+                   title="$gettext('Vorheriges Inhaltselement')">
+                    <studip-icon shape="arr_1left" :size="20"></studip-icon>
+                </a>
+                <div v-else></div>
+
+                <a :href="getDescriptionURL(highlightedModule)" data-dialog v-cloak class="contentmodule">
+                    <div class="iconwrapper">
+                        <img :src="highlightedModule.icon" width="60" height="60" v-cloak>
+                    </div>
+                    <div class="text">
+                        <div class="title" v-cloak>{{ highlightedModule.toolname }}</div>
+                        <div v-if="highlightedModule.highlight_text" v-cloak>
+                            {{ highlightedModule.highlight_text }}
+                        </div>
+                    </div>
+                </a>
+
+                <a href="#"
+                   @click.prevent="highlightIndex++"
+                   v-if="highlightIndex < highlighted.length - 1"
+                   :title="$gettext('Nächstes Inhaltselement')">
+                    <studip-icon shape="arr_1right" :size="20"></studip-icon>
+                </a>
+                <div v-else></div>
+            </div>
+        </div>
+
+        <component :is="displayComponent"
+                   :modules="modules"
+                   :filtercategory="filterCategory"
+        ></component>
+
+        <MountingPortal mount-to="#tool-view-switch .sidebar-widget-content .widget-list" name="sidebar-switch">
+            <ul class="widget-list widget-links sidebar-views">
+                <li :class="{ active: view === 'tiles' }">
+                    <a href="#" @click.prevent="changeView('tiles')">
+                        {{ $gettext('Kachelansicht') }}
+                    </a>
+                </li>
+                <li :class="{ active: view === 'table' }">
+                    <a href="#" @click.prevent="changeView('table')">
+                        {{ $gettext('Tabellarische Ansicht') }}
+                    </a>
+                </li>
+            </ul>
+        </MountingPortal>
+
+        <MountingPortal mount-to="#tool-filter-category .sidebar-widget-content .widget-list" name="sidebar-filter">
+            <ul class="widget-list widget-options">
+                <li>
+                    <a class="options-radio"
+                       :class="filterCategory === null ? 'options-checked' : 'options-unchecked'"
+                       role="radio"
+                       :aria-checked="filterCategory === null ? 'true' : 'false'"
+                       href="#"
+                       @click.prevent="setFilterCategory(null)"
+                    >
+                        {{ $gettext('Alle Kategorien') }}
+                    </a>
+                </li>
+                <li v-for="category in categories" :key="category">
+                    <a class="options-radio"
+                       :class="filterCategory === category ? 'options-checked' : 'options-unchecked'"
+                       href="#"
+                       role="radio"
+                       :aria-checked="filterCategory === category ? 'true' : 'false'"
+                       @click.prevent="setFilterCategory(category)"
+                    >
+                        {{ category }}
+                    </a>
+                </li>
+            </ul>
+        </MountingPortal>
+    </form>
+</template>
+<script>
+import ContentModulesEditTable from './ContentmodulesEditTable.vue';
+import ContentModulesEditTiles from './ContentModulesEditTiles.vue';
+import ContentModulesMixin from '../mixins/ContentModulesMixin.js';
+import { mapMutations, mapState } from 'vuex';
+
+export default {
+    name: 'ContentModules',
+    mixins: [ContentModulesMixin],
+    data: () => ({
+        highlightIndex: 0,
+    }),
+    computed: {
+        ...mapState('contentmodules', [
+            'highlighted',
+        ]),
+        isTilesDisplay() {
+            return this.view === 'tiles';
+        },
+        displayComponent() {
+            return this.isTilesDisplay ? ContentModulesEditTiles : ContentModulesEditTable;
+        },
+        highlightedModule() {
+            const id = this.highlighted[this.highlightIndex];
+            return this.$store.getters['contentmodules/getModuleById'](id);
+        },
+    },
+    methods: {
+        ...mapMutations('contentmodules', [
+            'setFilterCategory',
+        ]),
+    },
+};
+</script>
+<style lang="scss">
+.admin_contentmodules {
+    .drag-handle {
+        display: inline-block;
+
+        width: 6px;
+        height: 20px;
+        margin-top: 5px;
+
+        background-size: auto 20px;
+    }
+}
+
+.admin_contentmodules-move, /* apply transition to moving elements */
+.admin_contentmodules-enter-active,
+.admin_contentmodules-leave-active {
+}
+
+.admin_contentmodules-enter-from,
+.admin_contentmodules-leave-to {
+    opacity: 0;
+    transform: translateX(30px) translateY(30px);
+}
+
+/* ensure leaving items are taken out of layout flow so that moving
+   animations can be calculated correctly. */
+.admin_contentmodules-leave-active {
+    position: absolute;
+}
+</style>
+<style lang="scss" scoped>
+.infopanel {
+    padding: 10px;
+    background-color: var(--content-color-20);
+    width: 840px;
+    max-width: 100%;
+    box-sizing: border-box;
+    height: 200px;
+    max-height: 200px;
+    overflow: hidden;
+    text-align: center;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    justify-content: center;
+    margin-bottom: 10px;
+
+    .table-display & {
+        width: unset;
+    }
+
+    > .top {
+        width: 100%;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        > h2 {
+            font-weight: normal;
+            margin-top: 5px;
+        }
+    }
+
+    > .navigation_wrapper {
+        display: flex;
+        flex-direction: row;
+        justify-content: space-between;
+        align-items: center;
+        width: 100%;
+        > * {
+            min-width: 20px;
+            min-height: 20px;
+        }
+        > .contentmodule {
+            display: flex;
+            flex-direction: row;
+            .iconwrapper {
+                background-color: white;
+                border-radius: 50px;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                width: 90px;
+                height: 90px;
+                margin-right: 20px;
+            }
+            .title {
+                margin-top: 10px;
+                font-size: 1.3em;
+                font-weight: bold;
+            }
+        }
+    }
+
+
+    .back-button {
+        float: left;
+        position: relative;
+        top: 20px;
+    }
+}
+</style>
diff --git a/resources/vue/components/ContentModulesControl.vue b/resources/vue/components/ContentModulesControl.vue
new file mode 100644
index 00000000000..0bfcc0d7e5d
--- /dev/null
+++ b/resources/vue/components/ContentModulesControl.vue
@@ -0,0 +1,91 @@
+<template>
+    <div class="controls">
+        <div>
+            <label v-if="!module.mandatory">
+                <input type="checkbox" :checked="module.active" @click="toggleModuleActivation(module)" :ref="'checkbox_' + module.id">
+                {{ module.active ? $gettext('Werkzeug ist aktiv') : $gettext('Werkzeug ist inaktiv') }}
+            </label>
+        </div>
+        <div>
+            <a href="#"
+               class="toggle_visibility"
+               role="checkbox"
+               v-if="module.active && !module.mandatory"
+               :aria-checked="module.visibility !== 'tutor' ? 'true' : 'false'"
+               @click.prevent="toggleModuleVisibility(module)">
+                <studip-icon :shape="module.visibility !== 'tutor' ? 'visibility-visible' : 'visibility-invisible'"
+                             class="text-bottom"
+                             :title="$gettextInterpolate($gettext('Inhaltsmoduls %{ name } für Teilnehmende unsichtbar bzw. sichtbar schalten'), { name: module.displayname})"></studip-icon>
+            </a>
+        </div>
+    </div>
+</template>
+<script>
+import ContentModulesMixin from '../mixins/ContentModulesMixin.js';
+
+export default {
+    name: 'ContentModulesControl',
+    props: {
+        module_id: {
+            type: String,
+            required: true
+        }
+    },
+    mixins: [ContentModulesMixin],
+    computed: {
+        module () {
+            return this.modules.find(m => m.id == this.module_id) ?? null;
+        }
+    }
+};
+</script>
+<style lang="scss">
+.contentmodule_info {
+    display: flex;
+    > .main_part {
+        > .header {
+            display: flex;
+            align-items: center;
+            > .image {
+                width: 200px;
+                height: 150px;
+                display: flex;
+                justify-content: center;
+                align-items: center;
+            }
+            > .text {
+                display: flex;
+                flex-direction: column;
+            }
+
+        }
+        > .controls {
+            background-color: var(--content-color-20);
+            padding: 5px;
+            display: flex;
+            justify-content: space-between;
+        }
+        > .keywords {
+            margin-top: 10px;
+            margin-bottom: 10px;
+            padding-left: 25px;
+        }
+        > .description {
+            margin-top: 10px;
+        }
+    }
+    > .screenshots {
+        margin-left: 10px;
+        max-width: 270px;
+        > li {
+            margin-top: 20px;
+            margin-bottom: 20px;
+            img {
+                display: block;
+                width: 100%;
+            }
+        }
+
+    }
+}
+</style>
diff --git a/resources/vue/components/ContentModulesEditTiles.vue b/resources/vue/components/ContentModulesEditTiles.vue
new file mode 100644
index 00000000000..2edd4393968
--- /dev/null
+++ b/resources/vue/components/ContentModulesEditTiles.vue
@@ -0,0 +1,165 @@
+<template>
+    <draggable v-model="sortedModules" handle=".dragarea">
+        <transition-group name="admin_contentmodules"
+                          class="admin_contentmodules studip-grid"
+                          tag="div"
+                          role="listbox"
+        >
+            <div v-for="module in sortedModules"
+                 :key="module.id"
+                 role="option"
+                 class="studip-grid-element"
+                 :class="getModuleCSSClasses(module, activated[module.id])"
+                 v-cloak
+            >
+                <div>
+                    <a :class="'upper_part' + (module.active && filterCategory === null ? ' dragarea' : '')" :href="getDescriptionURL(module)" data-dialog>
+                        <div>
+                            <img :src="module.icon" width="40" height="40" v-if="module.icon">
+                        </div>
+                        <div>
+                            <h3>{{module.displayname}}</h3>
+                            {{module.summary}}
+                        </div>
+                    </a>
+                    <div class="down_part">
+                        <div>
+                            <a class="dragarea"
+                               tabindex="0"
+                               :title="$gettextInterpolate('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})"
+                               @keydown="keyboardHandler($event, module)"
+                               v-if="module.active && filterCategory === null"
+                               :ref="`draghandle-${module.id}`">
+                                <span class="drag-handle"></span>
+                            </a>
+                            <label v-if="!module.mandatory">
+                                <input type="checkbox" :checked="activated[module.id]" @click="toggleModule(module)" :ref="'checkbox_' + module.id">
+                                {{ module.active ? $gettext('Werkzeug ist aktiv') : $gettext('Werkzeug ist inaktiv') }}
+                            </label>
+                        </div>
+
+                        <div class="icons_right">
+                            <a href="#"
+                               class="toggle_visibility"
+                               role="checkbox"
+                               v-if="module.active && !module.mandatory"
+                               :aria-checked="module.visibility !== 'tutor' ? 'true' : 'false'"
+                               @click.prevent="toggleModuleVisibility(module)">
+                                <studip-icon :shape="module.visibility !== 'tutor' ? 'visibility-visible' : 'visibility-invisible'"
+                                             class="text-bottom"
+                                             :title="$gettextInterpolate($gettext('Inhaltsmoduls %{ name } für Teilnehmende unsichtbar bzw. sichtbar schalten'), { name: module.displayname})"></studip-icon>
+                            </a>
+                            <a :href="getRenameURL(module)" data-dialog="size=medium" v-if="module.active">
+                                <studip-icon shape="edit"
+                                             class="text-bottom"
+                                             :title="$gettextInterpolate($gettext('Umbenennen des Inhaltsmoduls %{ name }'), { name: module.displayname })"></studip-icon>
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </transition-group>
+    </draggable>
+</template>
+<script>
+import Vue from 'vue';
+import { mapState } from 'vuex';
+import ContentModulesMixin from '../mixins/ContentModulesMixin.js';
+
+export default {
+    name: 'ContentModules',
+    mixins: [ContentModulesMixin],
+    data: () => ({
+        activated: {},
+        timeouts: {},
+    }),
+    computed: {
+        ...mapState('contentmodules', [
+            'modules'
+        ]),
+    },
+    methods: {
+        toggleModule(module) {
+            Vue.set(this.activated, module.id, !this.activated[module.id]);
+
+            if (this.timeouts[module.id] ?? null) {
+                clearTimeout(this.timeouts[module.id] ?? null);
+                this.timeouts[module.id] = null;
+            }  else {
+                this.timeouts[module.id] = setTimeout(() => {
+                    this.toggleModuleActivation(module);
+                    this.timeouts[module.id] = null;
+                }, 700);
+            }
+        },
+    },
+    watch: {
+        modules: {
+            immediate: true,
+            handler(current) {
+                current.forEach(module => Vue.set(this.activated, module.id, module.active));
+            }
+        }
+    }
+}
+</script>
+<style lang="scss" scoped>
+.studip-grid-element {
+    display: flex;
+    flex-direction: row;
+    background-color: var(--white);
+    border-left: 1px solid var(--dark-gray-color-60);
+    transition: all 500ms ease, border-left-color 300ms ease;
+    &.visibility-visible {
+        border-left-color: var(--green);
+        > div {
+            border-left-color: var(--green);
+        }
+    }
+    &.visibility-invisible {
+        border-left-color: var(--yellow);
+        > div {
+            border-left-color: var(--yellow);
+        }
+    }
+    > div {
+        display: flex;
+        flex-direction: column;
+        justify-content: space-between;
+        transition: all 500ms ease, border-left-color 300ms ease;
+        border-left: 10px solid var(--dark-gray-color-60);
+        min-height: 150px;
+        width: 100%;
+
+        > .upper_part {
+            display: flex;
+            > :first-child {
+                padding: 10px 5px 10px 15px;
+            }
+            > :last-child {
+                padding: 10px 10px 20px;
+
+                h3 {
+                    margin-top: 0;
+                    color: var(--base-color);
+                }
+            }
+        }
+        > .down_part {
+            background-color: var(--content-color-20);
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            min-height: 30px;
+            padding-left: 5px;
+            > div {
+                display: flex;
+                align-items: center;
+            }
+            .icons_right > a {
+                margin-right: 8px;
+            }
+        }
+    }
+}
+</style>
diff --git a/resources/vue/components/ContentmodulesEditTable.vue b/resources/vue/components/ContentmodulesEditTable.vue
new file mode 100644
index 00000000000..144f920a7ac
--- /dev/null
+++ b/resources/vue/components/ContentmodulesEditTable.vue
@@ -0,0 +1,100 @@
+<template>
+    <table class="admin_contentmodules table default">
+        <colgroup>
+            <col style="width: 20px" v-if="filterCategory === null">
+            <col style="width: 20px">
+            <col>
+            <col style="width: 24px">
+        </colgroup>
+        <thead>
+        <tr>
+            <th v-if="filterCategory === null"></th>
+            <th></th>
+            <th>{{ $gettext('Name') }}</th>
+            <th class="actions">{{ $gettext('Aktionen') }}</th>
+        </tr>
+        </thead>
+
+        <draggable v-model="sortedModules" handle=".dragarea" tag="tbody">
+            <tr v-for="module in sortedModules"
+                :key="module.id"
+                :class="getModuleCSSClasses(module)"
+                v-cloak>
+                <td v-if="filterCategory === null">
+                    <a class="dragarea"
+                       tabindex="0"
+                       :title="$gettextInterpolate('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})"
+                       @keydown="keyboardHandler($event, module)"
+                       v-if="module.active"
+                       :ref="`draghandle-${module.id}`"
+                    >
+                        <span class="drag-handle"></span>
+                    </a>
+                </td>
+                <td>
+                    <input type="checkbox"
+                           v-model="module.active"
+                           @click="toggleModuleActivation(module)"
+                           v-if="!module.mandatory"
+                           :ref="'checkbox_' + module.id">
+                </td>
+                <td>
+                    <a class="upper_part"
+                       :class="{ dragrea: module.active }"
+                       :href="getDescriptionURL(module)"
+                       data-dialog
+                    >
+                        <img :src="module.icon" width="20" height="20" v-if="module.icon" class="text-bottom">
+                        {{ module.displayname }}
+                    </a>
+                </td>
+                <td class="actions">
+                    <a href="#"
+                       v-if="module.active && !module.mandatory"
+                       role="checkbox"
+                       :aria-checked="module.visibility !== 'tutor' ? 'true' : 'false'"
+                       @click.prevent="toggleModuleVisibility(module)">
+                        <studip-icon :shape="module.visibility !== 'tutor' ? 'visibility-visible' : 'visibility-invisible'"
+                                     class="text-bottom"
+                                     :title="$gettextInterpolate($gettext('Inhaltsmoduls %{ name } für Teilnehmende unsichtbar bzw. sichtbar schalten'), { name: module.displayname })"></studip-icon>
+                    </a>
+                    <a :href="getRenameURL(module)" data-dialog="size=auto" v-if="module.active">
+                        <studip-icon shape="edit" class="text-bottom" :title="$gettextInterpolate($gettext('Umbenennen des Inhaltsmoduls %{ name }'), { name: module.displayname })"></studip-icon>
+                    </a>
+                </td>
+            </tr>
+        </draggable>
+    </table>
+</template>
+
+<script>
+import ContentModulesMixin from '../mixins/ContentModulesMixin.js';
+
+export default {
+    name: 'contentmodules-edit-table',
+
+    mixins: [ContentModulesMixin],
+}
+</script>
+<style lang="scss">
+@use '../../assets/stylesheets/mixins/colors.scss';
+
+table.admin_contentmodules > tbody > tr  {
+    > td:first-child {
+        background-image: linear-gradient(colors.$dark-gray-color-60, colors.$dark-gray-color-60);
+        background-repeat: no-repeat;
+        background-position: left;
+        background-size: 10px auto;
+        padding-left: 15px;
+    }
+    &.visibility-visible > td:first-child {
+        background-image: linear-gradient(colors.$green, colors.$green);
+    }
+    &.visibility-invisible > td:first-child {
+        background-image: linear-gradient(colors.$yellow, colors.$yellow);
+    }
+    > td {
+        height: 31px; //to make all rows equally high
+    }
+}
+</style>
diff --git a/resources/vue/components/Datetimepicker.vue b/resources/vue/components/Datetimepicker.vue
index 87f6dfd2940..6b12ad78abc 100644
--- a/resources/vue/components/Datetimepicker.vue
+++ b/resources/vue/components/Datetimepicker.vue
@@ -33,8 +33,12 @@ export default {
         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));
+            if (date) {
+                date = new Date(`${date[2]}-${date[1]}-${date[0]} ${date[3]}:${date[4]}`);
+                this.$emit('input', Math.floor(date / 1000));
+            } else {
+                this.$emit('input', null);
+            }
         }
     },
     mounted () {
diff --git a/resources/vue/components/I18nTextarea.vue b/resources/vue/components/I18nTextarea.vue
index 79a810dbf29..6d31d7b9464 100644
--- a/resources/vue/components/I18nTextarea.vue
+++ b/resources/vue/components/I18nTextarea.vue
@@ -144,12 +144,14 @@ export default {
             values[this.selectedLanguage.id] = this.value;
             this.values = values;
         }
+        this.$emit('selectlanguage', this.selectedLanguage.id);
     },
     methods: {
         selectLanguage (e) {
             for (let i in this.languages) {
                 if (e.target.value === this.languages[i].id) {
                     this.selectedLanguage = this.languages[i];
+                    this.$emit('selectlanguage', this.languages[i].id);
                     this.$nextTick(() => {
                         if (typeof this.$refs.inputfield.focus === "function") {
                             this.$refs.inputfield.focus();
diff --git a/resources/vue/mixins/ContentModulesMixin.js b/resources/vue/mixins/ContentModulesMixin.js
new file mode 100644
index 00000000000..7df586dda28
--- /dev/null
+++ b/resources/vue/mixins/ContentModulesMixin.js
@@ -0,0 +1,125 @@
+import draggable from 'vuedraggable';
+import { mapActions, mapState } from 'vuex';
+
+export default {
+    components: {
+        draggable,
+    },
+    data: () => ({
+        order: [],
+    }),
+    computed: {
+        ...mapState('contentmodules', [
+            'categories',
+            'filterCategory',
+            'highlighted',
+            'modules',
+            'view',
+        ]),
+        activeModules() {
+            return this.sortedModules.filter(module => module.active);
+        },
+        sortedModules: {
+            get() {
+                return Object.values(this.modules)
+                    .filter(module => {
+                        return this.filterCategory === null
+                            || this.filterCategory === module.category;
+                    })
+                    .sort(function (a, b) {
+                        if (a.active && !b.active) {
+                            return -1;
+                        } else if (!a.active && b.active) {
+                            return 1;
+                        } else if (a.active) {
+                            return a.position - b.position;
+                        } else {
+                            return a.displayname.localeCompare(b.displayname);
+                        }
+                    });
+            },
+            set(modules) {
+                let position = 0;
+                for (const key in modules) {
+                    modules[key].position = position++;
+                }
+                this.exchangeModules(modules).then((output) => {
+                    if (output.tabs) {
+                        $('.tabs_wrapper').replaceWith(output.tabs);
+                    }
+                });
+            },
+        },
+    },
+    methods: {
+        ...mapActions('contentmodules', [
+            'changeView',
+            'exchangeModules',
+            'setModuleActive',
+            'setModuleVisible',
+            'swapModules',
+        ]),
+        keyboardHandler(event, module) {
+            const activeIndex = this.activeModules.findIndex(m => m.id === module.id);
+
+            let otherModule = null;
+            if (event.key === 'ArrowUp' && activeIndex > 0) {
+                otherModule = this.activeModules[activeIndex - 1];
+            } else if (event.key === 'ArrowDown' && activeIndex !== this.activeModules.length - 1) {
+                otherModule = this.activeModules[activeIndex + 1];
+            }
+
+            if (otherModule === null) {
+                return;
+            }
+
+            event.preventDefault();
+
+            this.swapModules({
+                moduleA: module,
+                moduleB: otherModule,
+            }).then((output) => {
+                if (output.tabs) {
+                    $('.tabs_wrapper').replaceWith(output.tabs);
+                }
+            }).then(() => {
+                this.$nextTick(() => {
+                    this.$refs[`draghandle-${module.id}`][0].focus();
+                });
+            });
+        },
+        toggleModuleActivation(module) {
+            this.setModuleActive({
+                moduleId: module.id,
+                active: !module.active,
+            }).then((output) => {
+                if (output.tabs) {
+                    $('.tabs_wrapper').replaceWith(output.tabs);
+                }
+            });
+        },
+        toggleModuleVisibility(module) {
+            this.setModuleVisible({
+                moduleId: module.id,
+                visible: module.visibility === 'tutor',
+            }).then((output) => {
+                if (output.tabs) {
+                    $('.tabs_wrapper').replaceWith(output.tabs);
+                }
+            });
+        },
+        getRenameURL(module) {
+            return STUDIP.URLHelper.getURL(`dispatch.php/course/contentmodules/rename/${module.id}`);
+        },
+        getDescriptionURL(module) {
+            return STUDIP.URLHelper.getURL(`dispatch.php/course/contentmodules/info/${module.id}`);
+        },
+        getModuleCSSClasses(module, active= null) {
+            if (!(active ?? module.active)) {
+                return 'inactive';
+            }
+
+            return module.visibility === 'tutor' ? 'visibility-invisible' : 'visibility-visible';
+        },
+    },
+};
diff --git a/resources/vue/store/ContentModulesStore.js b/resources/vue/store/ContentModulesStore.js
new file mode 100644
index 00000000000..9dc4609630f
--- /dev/null
+++ b/resources/vue/store/ContentModulesStore.js
@@ -0,0 +1,124 @@
+export default {
+    namespaced: true,
+
+    state: () => ({
+        categories: [],
+        filterCategory: null,
+        highlighted: [],
+        modules: [],
+        userId: null,
+        view: 'tiles',
+    }),
+    getters: {
+        getModuleById: (state) => (moduleId) => {
+            return state.modules.find(module => module.id === moduleId);
+        },
+    },
+    mutations: {
+        setCategories(state, categories) {
+            state.categories = categories;
+        },
+        setFilterCategory(state, category) {
+            state.filterCategory = category;
+        },
+        setHighlighted(state, highlighted) {
+            state.highlighted = highlighted;
+        },
+        setModule(state, module) {
+            let modules = state.modules.filter(m => m.id !== module.id);
+            modules.push(module);
+
+            state.modules = modules;
+        },
+        setModules(state, modules) {
+            state.modules = modules;
+        },
+        setUserId(state, userId) {
+            state.userId = userId;
+        },
+        setView(state, view) {
+            state.view = view;
+        },
+    },
+    actions: {
+        changeView({ commit, state }, view) {
+            commit('setView', view);
+
+            const documentId = `${state.userId}_CONTENTMODULES_TILED_DISPLAY`;
+
+            const data = {
+                id: documentId,
+                type: 'config-values',
+                attributes: { value: view === 'tiles' }
+            };
+
+            return STUDIP.jsonapi.PATCH(`config-values/${documentId}`, { data: { data } }) ;
+        },
+        exchangeModules({ commit, state }, modules) {
+            const order = modules.filter(module => module.active)
+                .sort((a, b) => a.position - b.position)
+                .map(module => module.id);
+            return $.post(
+                STUDIP.URLHelper.getURL('dispatch.php/course/contentmodules/reorder'),
+                { order }
+            ).then((output) => {
+                commit('setModules', modules);
+
+                return output;
+            });
+        },
+        setModuleActive({ commit, state, getters }, { moduleId, active }) {
+            const module = getters.getModuleById(moduleId);
+            module.active = active;
+
+            return $.post(
+                STUDIP.URLHelper.getURL('dispatch.php/course/contentmodules/trigger'),
+                {
+                    moduleclass: module.moduleclass,
+                    plugin_id: module.id,
+                    active: module.active ? 1 : 0
+                }
+            ).done((output) => {
+                module.position = output.position;
+                commit('setModule', module);
+
+                return output;
+            });
+        },
+        setModuleVisible({ commit, state, getters }, { moduleId, visible }) {
+            const module = getters.getModuleById(moduleId);
+
+            return $.post(
+                STUDIP.URLHelper.getURL('dispatch.php/course/contentmodules/change_visibility'),
+                {
+                    moduleclass: module.moduleclass,
+                    plugin_id: module.id,
+                    visible: visible ? 1 : 0,
+                }
+            ).done((output) => {
+                module.visibility = output.visibility;
+                commit('setModule', module);
+            });
+        },
+        swapModules({ dispatch, state, getters }, { moduleA, moduleB }) {
+            let modules = state.modules.map(module => {
+                if (module.id === moduleA.id) {
+                    return {
+                        ...moduleA,
+                        position: moduleB.position,
+                    };
+                }
+
+                if (module.id === moduleB.id) {
+                    return {
+                        ...moduleB,
+                        position: moduleA.position,
+                    };
+                }
+
+                return module;
+            });
+            return dispatch('exchangeModules', modules);
+        },
+    }
+}
diff --git a/templates/forms/form.php b/templates/forms/form.php
index 21eb9c80f83..094805e9e59 100644
--- a/templates/forms/form.php
+++ b/templates/forms/form.php
@@ -24,6 +24,7 @@ $form_id = md5(uniqid());
       novalidate
       id="<?= htmlReady($form_id) ?>"
       data-inputs="<?= htmlReady(json_encode($inputs)) ?>"
+      data-debugmode="<?= htmlReady(json_encode($form->getDebugMode())) ?>"
       data-required="<?= htmlReady(json_encode($required_inputs)) ?>"
       class="default studipform<?= $form->isCollapsable() ? ' collapsable' : '' ?>">
 
@@ -31,7 +32,7 @@ $form_id = md5(uniqid());
 
     <article aria-live="assertive"
              class="validation_notes studip"
-             v-if="(STUDIPFORM_REQUIRED.length > 0) || STUDIPFORM_DISPLAYVALIDATION">
+             v-if="STUDIPFORM_REQUIRED.length > 0 || STUDIPFORM_VALIDATIONNOTES.length > 0">
         <header>
             <h1>
                 <?= Icon::create('info-circle', Icon::ROLE_INFO)->asImg(17, ['class' => "text-bottom validation_notes_icon"]) ?>
diff --git a/templates/forms/i18n_formatted_input.php b/templates/forms/i18n_formatted_input.php
index 4e667f5f2bf..6466731b1a7 100644
--- a/templates/forms/i18n_formatted_input.php
+++ b/templates/forms/i18n_formatted_input.php
@@ -12,6 +12,7 @@
                    name="<?= htmlReady($name) ?>"
                    value="<?= htmlReady($value) ?>"
                    @allinputs="setInputs"
+                   @selectlanguage="(language_id) => selectLanguage('<?= htmlReady($this->name) ?>', language_id)"
                    :wysiwyg_disabled="<?= \Config::get()->WYSIWYG ? 'false' : 'true' ?>" <?= $required ? 'required' : '' ?>>
     </i18n-textarea>
     </div>
diff --git a/templates/forms/i18n_textarea_input.php b/templates/forms/i18n_textarea_input.php
index 3209b6827e5..d9b2ff3f809 100644
--- a/templates/forms/i18n_textarea_input.php
+++ b/templates/forms/i18n_textarea_input.php
@@ -12,6 +12,7 @@
                    name="<?= htmlReady($this->name) ?>"
                    value="<?= htmlReady($value) ?>"
                    <?= $required ? 'required' : '' ?>
+                   @selectlanguage="(language_id) => selectLanguage('<?= htmlReady($this->name) ?>', language_id)"
                    @allinputs="setInputs">
     </i18n-textarea>
 </div>
diff --git a/templates/forms/info_input.php b/templates/forms/info_input.php
new file mode 100644
index 00000000000..7461b7c20d5
--- /dev/null
+++ b/templates/forms/info_input.php
@@ -0,0 +1,8 @@
+<div class="formpart">
+    <article class="studip">
+        <header>
+            <h1><?= htmlReady($title) ?></h1>
+        </header>
+        <?= formatReady($value) ?>
+    </article>
+</div>
diff --git a/templates/forms/wysiwyg_input.php b/templates/forms/wysiwyg_input.php
new file mode 100644
index 00000000000..989bb5c7314
--- /dev/null
+++ b/templates/forms/wysiwyg_input.php
@@ -0,0 +1,16 @@
+<div class="formpart">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <studip-wysiwyg
+                   id="<?= $id ?>"
+                   v-model="<?= htmlReady($name) ?>"
+                   value="<?= htmlReady($value) ?>"
+                   <?= $required ? 'required' : '' ?>>
+    </studip-wysiwyg>
+    </div>
-- 
GitLab