From a9c40e363b3e13223bb771105c190be9475021a5 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Thu, 4 Jul 2024 13:31:50 +0000
Subject: [PATCH] introduce [data-vue-app], fixes #4294

Closes #4294, #4295, #4297, #4298, #4301, #4304, and #4306

Merge request studip/studip!3109
---
 app/controllers/admin/cache.php               |  28 +-
 app/controllers/admin/courses.php             |   8 +-
 app/controllers/admin/tree.php                |  42 ++-
 app/controllers/course/contentmodules.php     |  22 +-
 app/controllers/my_courses.php                |  20 +-
 app/controllers/search/courses.php            |  15 ++
 app/views/admin/cache/settings.php            |  14 -
 app/views/admin/courses/index.php             |  34 +--
 app/views/admin/tree/batch_assign_semtree.php |  10 +-
 app/views/admin/tree/rangetree.php            |   9 -
 app/views/admin/tree/semtree.php              |  10 -
 app/views/course/contentmodules/index.php     |   1 -
 app/views/course/contentmodules/info.php      |   4 +-
 app/views/my_courses/index.php                |  11 +-
 app/views/search/courses/index.php            |  16 --
 lib/classes/Debug/VueCollector.php            |  97 +++++++
 lib/classes/StudipController.php              |  11 +
 lib/classes/VueApp.php                        | 194 +++++++++++++
 .../javascripts/bootstrap/admin-courses.js    |  26 --
 .../javascripts/bootstrap/cache-admin.js      |  21 --
 .../javascripts/bootstrap/contentmodules.js   |  45 ----
 .../javascripts/bootstrap/my-courses.js       |  22 --
 .../bootstrap/responsive-navigation.js        |  10 -
 .../assets/javascripts/bootstrap/treeview.js  |  13 -
 resources/assets/javascripts/bootstrap/vue.js |  80 +++---
 resources/assets/javascripts/entry-base.js    |   8 -
 .../assets/javascripts/lib/admin-courses.js   |  15 +-
 .../assets/javascripts/lib/studip-vue.js      |  35 ++-
 resources/vue/components/AdminCourses.vue     | 254 +++++++++++-------
 .../vue/components/CacheAdministration.vue    |  11 +-
 templates/header.php                          |  12 +-
 templates/vue-app.php                         |  14 +
 32 files changed, 690 insertions(+), 422 deletions(-)
 delete mode 100644 app/views/admin/cache/settings.php
 delete mode 100644 app/views/admin/tree/rangetree.php
 delete mode 100644 app/views/admin/tree/semtree.php
 delete mode 100644 app/views/course/contentmodules/index.php
 delete mode 100644 app/views/search/courses/index.php
 create mode 100644 lib/classes/Debug/VueCollector.php
 create mode 100644 lib/classes/VueApp.php
 delete mode 100644 resources/assets/javascripts/bootstrap/cache-admin.js
 delete mode 100644 resources/assets/javascripts/bootstrap/contentmodules.js
 delete mode 100644 resources/assets/javascripts/bootstrap/my-courses.js
 delete mode 100644 resources/assets/javascripts/bootstrap/responsive-navigation.js
 delete mode 100644 resources/assets/javascripts/bootstrap/treeview.js
 create mode 100644 templates/vue-app.php

diff --git a/app/controllers/admin/cache.php b/app/controllers/admin/cache.php
index c016af5efd6..7bd70c64627 100644
--- a/app/controllers/admin/cache.php
+++ b/app/controllers/admin/cache.php
@@ -64,19 +64,21 @@ class Admin_CacheController extends AuthenticatedController
      */
     public function settings_action()
     {
-        if ($this->enabled) {
-            $this->types = CacheType::findAndMapBySQL(function (CacheType $type) {
-                return $type->toArray();
-            }, "1 ORDER BY `cache_id`");
-
-            $currentCache = Config::get()->SYSTEMCACHE;
-            $currentCacheClass = CacheType::findOneByClass_name($currentCache['type']);
-            $this->cache = $currentCacheClass->class_name;
-            $this->config = $currentCacheClass->class_name::getConfig();
-        } else {
-            PageLayout::postWarning(
-                _('Caching ist systemweit ausgeschaltet, daher kann hier nichts konfiguriert werden.'));
-        }
+        $currentCache = Config::get()->SYSTEMCACHE;
+        $currentCacheClass = CacheType::findOneByClass_name($currentCache['type']);
+
+        $this->render_vue_app(
+            Studip\VueApp::create('CacheAdministration')
+                ->withProps([
+                    'enabled'       => (bool) $this->enabled,
+                    'currentCache'  => $currentCacheClass->class_name,
+                    'currentConfig' => $currentCacheClass->class_name::getConfig(),
+                    'cacheTypes'    => CacheType::findAndMapBySQL(
+                        fn(CacheType $type) => $type->toArray(),
+                        "1 ORDER BY `cache_id`"
+                    ),
+                ])
+        );
     }
 
     /**
diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php
index 5f60442bb40..a10aa5cfe19 100644
--- a/app/controllers/admin/courses.php
+++ b/app/controllers/admin/courses.php
@@ -305,15 +305,9 @@ class Admin_CoursesController extends AuthenticatedController
         $this->fields = $this->getViewFilters();
         $this->sortby = $GLOBALS['user']->cfg->MEINE_SEMINARE_SORT ?? (Config::get()->IMPORTANT_SEMNUMBER ? 'number' : 'name');
         $this->sortflag = $GLOBALS['user']->cfg->MEINE_SEMINARE_SORT_FLAG ?? 'ASC';
+        $this->store_data = $this->getStoreData();
 
         $this->buildSidebar();
-
-        PageLayout::addHeadElement('script', [
-            'type' => 'text/javascript',
-        ], sprintf(
-              'window.AdminCoursesStoreData = %s;',
-              json_encode($this->getStoreData())
-        ));
     }
 
     private function getStoreData(): array
diff --git a/app/controllers/admin/tree.php b/app/controllers/admin/tree.php
index c8f2a8f1c4a..ec35368c262 100644
--- a/app/controllers/admin/tree.php
+++ b/app/controllers/admin/tree.php
@@ -7,10 +7,28 @@ class Admin_TreeController extends AuthenticatedController
         $GLOBALS['perm']->check('root');
         Navigation::activateItem('/admin/locations/range_tree');
         PageLayout::setTitle(_('Einrichtungshierarchie bearbeiten'));
-        $this->startId = Request::get('node_id', 'RangeTreeNode_root');
+
         $this->semester = Request::option('semester', Semester::findCurrent()->id);
         $this->classname = RangeTreeNode::class;
         $this->setupSidebar();
+
+        $this->render_vue_app(
+            Studip\VueApp::create('tree/StudipTree')
+                ->withProps([
+                    'breadcrumb-icon'              => 'institute',
+                    'create-url'                   => $this->createURL(),
+                    'delete-url'                   => $this->deleteURL(),
+                    'edit-url'                     => $this->editURL(),
+                    'editable'                     => true,
+                    'semester'                     => $this->semester,
+                    'show-structure-as-navigation' => true,
+                    'start-id'                     => Request::get('node_id', 'RangeTreeNode_root'),
+                    'title'                        => _('Einrichtungshierarchie bearbeiten'),
+                    'view-type'                    => 'table',
+                    'visible-children-only'        => false,
+                    'with-courses'                 => true,
+                ])
+        );
     }
 
     public function semtree_action()
@@ -18,10 +36,30 @@ class Admin_TreeController extends AuthenticatedController
         $GLOBALS['perm']->check('root');
         Navigation::activateItem('/admin/locations/sem_tree');
         PageLayout::setTitle(_('Veranstaltungshierarchie bearbeiten'));
-        $this->startId = Request::get('node_id', 'StudipStudyArea_root');
+
+
         $this->semester = Request::option('semester', Semester::findCurrent()->id);
         $this->classname = StudipStudyArea::class;
         $this->setupSidebar();
+
+        $this->render_vue_app(
+            Studip\VueApp::create('tree/StudipTree')
+                ->withProps([
+                    'breadcrumb-icon'              => 'literature',
+                    'create-url'                   => $this->createURL(),
+                    'delete-url'                   => $this->deleteURL(),
+                    'edit-url'                     => $this->editURL(),
+                    'editable'                     => true,
+                    'semester'                     => $this->semester,
+                    'show-structure-as-navigation' => true,
+                    'start-id'                     => Request::get('node_id', 'StudipStudyArea_root'),
+                    'title'                        => _('Veranstaltungshierarchie bearbeiten'),
+                    'view-type'                    => 'table',
+                    'visible-children-only'        => false,
+                    'with-course-assign'           => true,
+                    'with-courses'                 => true,
+                ])
+        );
     }
 
     /**
diff --git a/app/controllers/course/contentmodules.php b/app/controllers/course/contentmodules.php
index d37d1bb050a..d1ac7b48b37 100644
--- a/app/controllers/course/contentmodules.php
+++ b/app/controllers/course/contentmodules.php
@@ -83,18 +83,16 @@ class Course_ContentmodulesController extends AuthenticatedController
             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',
-            ])
-        ));
+        $this->render_vue_app(
+            Studip\VueApp::create('ContentModules')
+                ->withStore('ContentModulesStore', 'contentmodules', [
+                    '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()
diff --git a/app/controllers/my_courses.php b/app/controllers/my_courses.php
index 8205c12c743..e17fc91b38b 100644
--- a/app/controllers/my_courses.php
+++ b/app/controllers/my_courses.php
@@ -113,13 +113,13 @@ class MyCoursesController extends AuthenticatedController
         }
 
         $this->setupSidebar($sem_key, $group_field, $this->check_for_new($sem_courses, $group_field));
-        $data = $this->getMyCoursesData($sem_courses, $group_field);
 
-        PageLayout::addHeadElement(
-            'script',
-            ['type' => 'text/javascript'],
-            'window.STUDIP.MyCoursesData = ' . json_encode($data) . ';'
-        );
+        $this->vueApp = Studip\VueApp::create('MyCourses')
+            ->withStore(
+                'MyCoursesStore',
+                'mycourses',
+                $this->getMyCoursesData($sem_courses, $group_field)
+            );
     }
 
     /**
@@ -797,10 +797,10 @@ class MyCoursesController extends AuthenticatedController
         }
 
         return [
-            'courses' => $this->sanitizeNavigations(array_map([$this, 'convertCourse'], $temp_courses)),
-            'groups'  => $groups,
-            'user_id' => $GLOBALS['user']->id,
-            'config'  => [
+            'setCourses' => $this->sanitizeNavigations(array_map([$this, 'convertCourse'], $temp_courses)),
+            'setGroups'  => $groups,
+            'setUserId'  => $GLOBALS['user']->id,
+            'setConfig'  => [
                 'allow_dozent_visibility'  => Config::get()->ALLOW_DOZENT_VISIBILITY,
                 'open_groups'              => array_values($GLOBALS['user']->cfg->MY_COURSES_OPEN_GROUPS),
                 'sem_number'               => Config::get()->IMPORTANT_SEMNUMBER,
diff --git a/app/controllers/search/courses.php b/app/controllers/search/courses.php
index a6a4d272d11..76e3320e2bd 100644
--- a/app/controllers/search/courses.php
+++ b/app/controllers/search/courses.php
@@ -59,6 +59,21 @@ class Search_CoursesController extends AuthenticatedController
 
         $this->setupSidebar();
         PageLayout::setTitle($title);
+
+        $this->render_vue_app(
+            Studip\VueApp::create('tree/StudipTree')
+                ->withProps([
+                    'breadcrumb-icon' => $this->breadcrumbIcon,
+                    'sem-class'       => $this->semClass,
+                    'semester'        => $this->semester,
+                    'start-id'        => $this->startId,
+                    'title'           => $this->treeTitle,
+                    'view-type'       => $this->show_as,
+                    'with-courses'    => true,
+                    'with-export'     => true,
+                    'with-search'     => true,
+                ])
+        );
     }
 
     private function setupSidebar()
diff --git a/app/views/admin/cache/settings.php b/app/views/admin/cache/settings.php
deleted file mode 100644
index cf022d4d930..00000000000
--- a/app/views/admin/cache/settings.php
+++ /dev/null
@@ -1,14 +0,0 @@
-<?php
-/**
- * @var boolean $enabled
- * @var array $types
- * @var array $config
- * @var string $cache
- */
-?>
-<? if ($enabled) : ?>
-    <div id="cache-admin-container">
-        <cache-administration :cache-types='<?= htmlReady(json_encode($types)) ?>' current-cache="<?= htmlReady($cache) ?>"
-                     :current-config='<?= htmlReady(json_encode($config)) ?>'></cache-administration>
-    </div>
-<? endif;
diff --git a/app/views/admin/courses/index.php b/app/views/admin/courses/index.php
index 812790c6151..690366c0cae 100644
--- a/app/views/admin/courses/index.php
+++ b/app/views/admin/courses/index.php
@@ -9,6 +9,7 @@
  * @var string $sortflag
  * @var array $activeSidebarElements
  * @var int $max_show_courses
+ * @var array $store_data
  */
 
 $unsortable_fields = [
@@ -17,29 +18,18 @@ $unsortable_fields = [
     'contents'
 ];
 ?>
-
 <? if (empty($insts)): ?>
     <?= MessageBox::info(sprintf(_('Sie wurden noch keinen Einrichtungen zugeordnet. Bitte wenden Sie sich an einen der zuständigen %sAdministratoren%s.'), '<a href="' . URLHelper::getLink('dispatch.php/siteinfo/show') . '">', '</a>')) ?>
-<? else :
-
-    $attributes = [
-        ':show-complete' => json_encode((bool) Config::get()->ADMIN_COURSES_SHOW_COMPLETE),
-        ':fields' => json_encode($fields),
-        ':unsortable-fields' => json_encode($unsortable_fields),
-        ':max-courses' => (int) $max_show_courses,
-        'sort-by' => $sortby,
-        'sort-flag' => $sortflag,
-    ];
-?>
-    <form method="post">
-        <?= CSRFProtection::tokenTag() ?>
-
-        <div class="admin-courses-vue-app course-admin"
-             is="AdminCourses"
-             v-cloak
-             ref="app"
-             <?= arrayToHtmlAttributes($attributes) ?>
-        ></div>
-    </form>
+<? else: ?>
+    <?= Studip\VueApp::create('AdminCourses')
+            ->withProps([
+                'show-complete' => (bool) Config::get()->ADMIN_COURSES_SHOW_COMPLETE,
+                'fields' => $fields,
+                'unsortable-fields' => $unsortable_fields,
+                'max-courses' => (int) $max_show_courses,
+                'sort-by' => $sortby,
+                'sort-flag' => $sortflag,
+            ])
+            ->withStore('AdminCoursesStore', 'admincourses', $store_data) ?>
 
 <? endif; ?>
diff --git a/app/views/admin/tree/batch_assign_semtree.php b/app/views/admin/tree/batch_assign_semtree.php
index 4b1a4ff0649..6b7df0e2bbd 100644
--- a/app/views/admin/tree/batch_assign_semtree.php
+++ b/app/views/admin/tree/batch_assign_semtree.php
@@ -2,10 +2,12 @@
     <?= CSRFProtection::tokenTag() ?>
     <fieldset>
         <legend><?= _('Studienbereichszuordnungen der ausgewählten Veranstaltungen bearbeiten') ?></legend>
-        <div data-studip-tree>
-            <studip-tree start-id="StudipStudyArea_root" :with-info="false" :open-levels="1"
-                         :assignable="true"></studip-tree>
-        </div>
+        <?= Studip\VueApp::create('tree/StudipTree')->withProps([
+            'assignable'  => true,
+            'open-levels' => 1,
+            'start-id'    => 'StudipStudyArea_root',
+            'with-info'   => false,
+        ]) ?>
     </fieldset>
     <fieldset>
         <legend><?= _('Diese Veranstaltungen werden zugewiesen') ?></legend>
diff --git a/app/views/admin/tree/rangetree.php b/app/views/admin/tree/rangetree.php
deleted file mode 100644
index 1e3e9453f53..00000000000
--- a/app/views/admin/tree/rangetree.php
+++ /dev/null
@@ -1,9 +0,0 @@
-<div data-studip-tree>
-    <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="table" breadcrumb-icon="institute"
-                 :with-search="false" :visible-children-only="false"
-                 :editable="true" edit-url="<?= $controller->url_for('admin/tree/edit') ?>"
-                 create-url="<?= $controller->url_for('admin/tree/create') ?>"
-                 delete-url="<?= $controller->url_for('admin/tree/delete') ?>"
-                 :with-courses="true" semester="<?= htmlReady($semester) ?>" :show-structure-as-navigation="true"
-                 title="<?= _('Einrichtungshierarchie bearbeiten') ?>"></studip-tree>
-</div>
diff --git a/app/views/admin/tree/semtree.php b/app/views/admin/tree/semtree.php
deleted file mode 100644
index 0c48245a6ed..00000000000
--- a/app/views/admin/tree/semtree.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<div data-studip-tree>
-    <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="table" breadcrumb-icon="literature"
-                 :with-search="false" :visible-children-only="false"
-                 :editable="true" edit-url="<?= $controller->url_for('admin/tree/edit') ?>"
-                 create-url="<?= $controller->url_for('admin/tree/create') ?>"
-                 delete-url="<?= $controller->url_for('admin/tree/delete') ?>"
-                 :show-structure-as-navigation="true" :with-course-assign="true"
-                 :with-courses="true" semester="<?= htmlReady($semester) ?>"
-                 title="<?= _('Veranstaltungshierarchie bearbeiten') ?>"></studip-tree>
-</div>
diff --git a/app/views/course/contentmodules/index.php b/app/views/course/contentmodules/index.php
deleted file mode 100644
index af8f3e1ebaf..00000000000
--- a/app/views/course/contentmodules/index.php
+++ /dev/null
@@ -1 +0,0 @@
-<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
index 93d3ad9cb68..0585ebecef9 100644
--- a/app/views/course/contentmodules/info.php
+++ b/app/views/course/contentmodules/info.php
@@ -18,7 +18,9 @@
                 <? endif; ?>
                 </div>
             </div>
-            <div class="content-modules-controls-vue-app" is="ContentModulesControl" module_id="<?= htmlReady($plugin->getPluginId()) ?>"></div>
+            <?= Studip\VueApp::create('ContentModulesControl')->withProps([
+                 'module_id' => (string) $plugin->getPluginId(),
+            ]) ?>
             <? $keywords = preg_split( "/;/", $metadata['keywords'] ?? '', -1, PREG_SPLIT_NO_EMPTY) ?>
             <? if (count($keywords) > 0) : ?>
             <ul class="keywords">
diff --git a/app/views/my_courses/index.php b/app/views/my_courses/index.php
index 1bbfd7388e8..8ec7703a80b 100644
--- a/app/views/my_courses/index.php
+++ b/app/views/my_courses/index.php
@@ -1,10 +1,15 @@
+<?php
+/**
+ * @var Studip\VueApp $vueApp
+ * @var array $my_bosses
+ * @var array $waiting_list
+ */
+?>
 <? if ($waiting_list) : ?>
     <?= $this->render_partial('my_courses/waiting_list.php', compact('waiting_list')) ?>
 <? endif ?>
 
-<div class="my-courses-vue-app">
-    <my-courses />
-</div>
+<?= $vueApp->render() ?>
 
 <? if (count($my_bosses) > 0) : ?>
     <?= $this->render_partial('my_courses/_deputy_bosses'); ?>
diff --git a/app/views/search/courses/index.php b/app/views/search/courses/index.php
deleted file mode 100644
index 500c31e7f69..00000000000
--- a/app/views/search/courses/index.php
+++ /dev/null
@@ -1,16 +0,0 @@
-<?php
-/**
- * @var String $startId
- * @var String $show_as
- * @var String $treeTitle
- * @var String $breadcrumIcon
- * @var String $semester
- * @var String $semClass
- */
-?>
-<div data-studip-tree>
-    <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="<?= htmlReady($show_as) ?>" :visible-children-only="true"
-                 title="<?= htmlReady($treeTitle) ?>" breadcrumb-icon="<?= htmlReady($breadcrumbIcon) ?>"
-                 :with-search="true" :with-export="true" :with-courses="true" semester="<?= htmlReady($semester) ?>"
-                 :sem-class="<?= htmlReady($semClass) ?>" :with-export="true"></studip-tree>
-</div>
diff --git a/lib/classes/Debug/VueCollector.php b/lib/classes/Debug/VueCollector.php
new file mode 100644
index 00000000000..a2d90f3ab54
--- /dev/null
+++ b/lib/classes/Debug/VueCollector.php
@@ -0,0 +1,97 @@
+<?php
+namespace Studip\Debug;
+
+use DebugBar\DataCollector\DataCollector;
+use DebugBar\DataCollector\Renderable;
+use Studip\VueApp;
+
+final class VueCollector extends DataCollector implements Renderable
+{
+    public function __construct(
+        private readonly VueApp $app
+    ) {
+        $this->useHtmlVarDumper(false);
+    }
+
+    public function collect()
+    {
+        $data = [];
+
+        $props = $this->app->getProps();
+        if (count($props) > 0) {
+            ksort($props);
+
+            $data['== DATA =='] = count($props) . ' items';
+            foreach ($props as $key => $value) {
+                $data[$key] = $this->dumpVar($value);
+            }
+        }
+
+        $stores = $this->app->getStores();
+        if (count($stores) > 0) {
+            ksort($stores);
+            $storeData = $this->app->getStoreData();
+
+            $data['== STORES =='] = '';
+
+            foreach ($stores as $index => $store) {
+                $data[$index] = $store === $index ? '' : "({$store})";
+
+                $tmp = $storeData[$index] ?? [];
+                ksort($tmp);
+                foreach ($tmp as $key => $value) {
+                    $data["- {$key}"] = $this->dumpVar($value);
+                }
+            }
+        }
+
+        return $data;
+    }
+
+    public function getName()
+    {
+        return '[Vue]' . basename($this->app->getBaseComponent());
+    }
+
+    /**
+     * @return array
+     */
+    public function getAssets()
+    {
+        return $this->isHtmlVarDumperUsed() ? $this->getVarDumper()->getAssets() : [];
+    }
+
+    /**
+     * @return array[]
+     */
+    public function getWidgets()
+    {
+        $name = $this->getName();
+        $widget = $this->isHtmlVarDumperUsed()
+            ? 'PhpDebugBar.Widgets.HtmlVariableListWidget'
+            : 'PhpDebugBar.Widgets.VariableListWidget';
+
+
+        return [
+            $name => [
+                'icon'   => 'code',
+                'widget' => $widget,
+                'map' => $name,
+                'default' => '{}'
+            ],
+        ];
+    }
+
+    private function dumpVar(mixed $variable): string
+    {
+        if ($this->isHtmlVarDumperUsed()) {
+            return $this->getVarDumper()->renderVar($variable);
+        }
+
+        if (!is_string($variable)) {
+            return $this->getDataFormatter()->formatVar($variable);
+        }
+
+        return $variable;
+    }
+}
diff --git a/lib/classes/StudipController.php b/lib/classes/StudipController.php
index a908a477c37..f99511c543d 100644
--- a/lib/classes/StudipController.php
+++ b/lib/classes/StudipController.php
@@ -588,6 +588,17 @@ abstract class StudipController extends Trails\Controller
         $this->render_text($form->render());
     }
 
+    /**
+     * Renders a vue app
+     *
+     * Use this if the vue app is the only content located on the page so
+     * you don't have to create a template file.
+     */
+    public function render_vue_app(\Studip\VueApp $app): void
+    {
+        $this->render_template($app->getTemplate(), $this->layout);
+    }
+
     /**
      * relays current request to another controller and returns the response
      * the other controller is given all assigned properties, additional parameters are passed
diff --git a/lib/classes/VueApp.php b/lib/classes/VueApp.php
new file mode 100644
index 00000000000..0e209615f17
--- /dev/null
+++ b/lib/classes/VueApp.php
@@ -0,0 +1,194 @@
+<?php
+namespace Studip;
+
+use Flexi\Template;
+use Stringable;
+
+/**
+ * PHP abstraction of vue app
+ *
+ * The VueApp is used to create a Vue app in a general way. Just create it
+ * using the name of the case component and pass in any required props or
+ * stores including initial data.
+ *
+ * The store data is passed as an associative array where the key is the name
+ * of the mutation to call with the given value as data.
+ *
+ * All methods are written in fluid manner so that you can create the app like this:
+ *
+ * <code>
+ *     <?= Studip\VueApp::create('ExampleComponent')
+ *         ->withProps(['foo' => 'bar'])
+ *         ->withStore('exampleStore', data: ['setBar' => 'baz']) ?>
+ * </code>
+ *
+ * All with* methods will always create a new cloned instance so the original
+ * instance is immutable.
+ *
+ * @author Jan-Hendrik Willms <tleilax+studip@gmail.com>
+ * @since Stud.IP 6.0
+ */
+final class VueApp implements Stringable
+{
+    /**
+     * Creates a vue app with the given base component.
+     */
+    public static function create(string $base_component): VueApp
+    {
+        return new self($base_component);
+    }
+
+    private array $props = [];
+    private array $stores = [];
+    private array $storeData = [];
+
+    /**
+     * Private constructor since we want to enforce the use of VueApp::create().
+     */
+    private function __construct(
+        private readonly string $base_component
+    ) {
+    }
+
+    /**
+     * Returns the base component
+     */
+    public function getBaseComponent(): string
+    {
+        return $this->base_component;
+    }
+
+    /**
+     * Add props
+     *
+     * You may choose to overwrite the defined props
+     */
+    public function withProps(array $props, bool $overwrite = false): VueApp
+    {
+        $clone = clone $this;
+        $clone->props = [...$overwrite ? [] : $clone->props, ...$props];
+        return $clone;
+    }
+
+    /**
+     * Returns all props
+     */
+    public function getProps(): array
+    {
+        return $this->props;
+    }
+
+    /**
+     * Add a slot with the given name
+     *
+     * If you pass a flexi template as the content, it will be rendered.
+     */
+    public function withSlot(string $name, string|Template $content): VueApp
+    {
+        $this->slots[$name] = $content instanceof Template ? $content->render() : $content;
+        return $this;
+    }
+
+    /**
+     * Returns all slots
+     */
+    public function getSlots(): array
+    {
+        return $this->slots;
+    }
+
+    /**
+     * Adds a store
+     */
+    public function withStore(string $store, ?string $index = null, ?array $data = null): VueApp
+    {
+        $clone = clone $this;
+
+        $clone->stores[$index ?? $store] = $store;
+
+        if ($data !== null) {
+            $clone->storeData[$index ?? $store] = $data;
+        }
+
+        return $clone;
+    }
+
+    /**
+     * Returns all stores
+     */
+    public function getStores(): array
+    {
+        return $this->stores;
+    }
+
+    /**
+     * Returns all store data
+     */
+    public function getStoreData(): array
+    {
+        return $this->storeData;
+    }
+
+    /**
+     * Returns the template to render the vue app
+     */
+    public function getTemplate(): Template
+    {
+        $data = [
+            'components' => [$this->base_component],
+        ];
+
+        if (count($this->stores) > 0) {
+            $data['stores'] = $this->stores;
+        }
+
+        $template = $GLOBALS['template_factory']->open('vue-app.php');
+        $template->baseComponent = basename($this->base_component);
+        $template->attributes = ['data-vue-app' => json_encode($data)];
+        $template->props = $this->getPreparedProps();
+        $template->storeData = $this->storeData;
+        return $template;
+    }
+
+    /**
+     * Returns the props as required to include them in the html
+     */
+    private function getPreparedProps(): array
+    {
+        $result = [];
+        foreach ($this->props as $name => $value) {
+            $name = ltrim($name, ':');
+            $name = strtokebabcase($name);
+            $result[":{$name}"] = json_encode($value);
+        }
+        return $result;
+    }
+
+    /**
+     * Renders the vue app
+     */
+    public function render(): string
+    {
+        if (Debug\DebugBar::isActivated()) {
+            $debugbar = app()->get(\DebugBar\DebugBar::class);
+            $collector = new Debug\VueCollector($this);
+            $debugbar->addCollector($collector);
+        }
+
+        \NotificationCenter::postNotification('VueAppWillRender', $this);
+
+        $content = $this->getTemplate()->render();
+
+        \NotificationCenter::postNotification('VueAppDidRender', $this);
+
+        return $content;
+    }
+
+    /**
+     * Returns a string representation of the vue app by rendering it.
+     */
+    public function __toString(): string
+    {
+        return $this->render();
+    }
+}
diff --git a/resources/assets/javascripts/bootstrap/admin-courses.js b/resources/assets/javascripts/bootstrap/admin-courses.js
index 74802b6ae4d..273b97d91a3 100644
--- a/resources/assets/javascripts/bootstrap/admin-courses.js
+++ b/resources/assets/javascripts/bootstrap/admin-courses.js
@@ -1,30 +1,4 @@
 STUDIP.domReady(() => {
-    const node = document.querySelector('.admin-courses-vue-app');
-    if (!node) {
-        return;
-    }
-
-    Promise.all([
-        STUDIP.Vue.load(),
-        import('../../../vue/store/AdminCoursesStore.js').then((config) => config.default),
-        import('../../../vue/components/AdminCourses.vue').then((component) => component.default),
-    ]).then(([{ createApp, store }, storeConfig, AdminCourses]) => {
-        store.registerModule('admincourses', storeConfig);
-
-        Object.entries(window.AdminCoursesStoreData ?? {}).forEach(([key, value]) => {
-            store.commit(`admincourses/${key}`, value);
-        })
-
-        const vm = createApp({
-            components: { AdminCourses },
-        });
-        vm.$mount(node);
-
-        STUDIP.AdminCourses.App = vm.$refs.app;
-    });
-
-
-
     $('.admin-courses-options').find('.options-radio, .options-checkbox').on('click', function () {
         $(this).toggleClass(['options-checked', 'options-unchecked']);
         $(this).attr('aria-checked', $(this).is('.options-checked') ? 'true' : 'false');
diff --git a/resources/assets/javascripts/bootstrap/cache-admin.js b/resources/assets/javascripts/bootstrap/cache-admin.js
deleted file mode 100644
index 97b41073d22..00000000000
--- a/resources/assets/javascripts/bootstrap/cache-admin.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * Stud.IP: Administration of available cache types, like database, Memcached, Redis etc.
- *
- * @author    Thomas Hackl <studip@thomas-hackl.name>
- * @license   GPL2 or any later version
- * @copyright Stud.IP core group
- * @since     Stud.IP 5.0
- */
-
-import CacheAdministration from '../../../vue/components/CacheAdministration.vue'
-
-STUDIP.domReady(() => {
-    if (document.getElementById('cache-admin-container')) {
-        STUDIP.Vue.load().then(({ createApp }) => {
-            createApp({
-                el: '#cache-admin-container',
-                components: { CacheAdministration }
-            })
-        })
-    }
-});
diff --git a/resources/assets/javascripts/bootstrap/contentmodules.js b/resources/assets/javascripts/bootstrap/contentmodules.js
deleted file mode 100644
index 18963fd4e9c..00000000000
--- a/resources/assets/javascripts/bootstrap/contentmodules.js
+++ /dev/null
@@ -1,45 +0,0 @@
-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((event) => {
-    let target = event.target ?? document;
-    if (target instanceof jQuery) {
-        target = target.get(0);
-    }
-
-    const node = target.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/my-courses.js b/resources/assets/javascripts/bootstrap/my-courses.js
deleted file mode 100644
index 40e0c24dc8d..00000000000
--- a/resources/assets/javascripts/bootstrap/my-courses.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import MyCourses from '../../../vue/components/MyCourses.vue';
-import storeConfig from '../../../vue/store/MyCoursesStore.js';
-
-STUDIP.domReady(async () => {
-    if ($('.my-courses-vue-app').length === 0) {
-        return;
-    }
-
-    const { createApp, store } = await STUDIP.Vue.load();
-
-    store.registerModule('mycourses', storeConfig);
-
-    store.commit('mycourses/setCourses', window.STUDIP.MyCoursesData['courses']);
-    store.commit('mycourses/setGroups', window.STUDIP.MyCoursesData['groups']);
-    store.commit('mycourses/setUserId', window.STUDIP.MyCoursesData['user_id']);
-    store.commit('mycourses/setConfig', window.STUDIP.MyCoursesData['config']);
-
-    const vm = createApp({
-        components: { MyCourses }
-    });
-    vm.$mount('.my-courses-vue-app');
-});
diff --git a/resources/assets/javascripts/bootstrap/responsive-navigation.js b/resources/assets/javascripts/bootstrap/responsive-navigation.js
deleted file mode 100644
index ad39d2be092..00000000000
--- a/resources/assets/javascripts/bootstrap/responsive-navigation.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import ResponsiveNavigation from '../../../vue/components/responsive/ResponsiveNavigation.vue';
-
-STUDIP.domReady(() => {
-    STUDIP.Vue.load().then(({ createApp }) => {
-        createApp({
-            el: '#responsive-menu',
-            components: { ResponsiveNavigation }
-        });
-    });
-});
diff --git a/resources/assets/javascripts/bootstrap/treeview.js b/resources/assets/javascripts/bootstrap/treeview.js
deleted file mode 100644
index d132775a335..00000000000
--- a/resources/assets/javascripts/bootstrap/treeview.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import StudipTree from '../../../vue/components/tree/StudipTree.vue'
-
-STUDIP.ready(() => {
-    document.querySelectorAll('[data-studip-tree]:not(.vueified)').forEach(element => {
-        element.classList.add('vueified');
-        STUDIP.Vue.load().then(({ createApp }) => {
-            createApp({
-                el: element,
-                components: { StudipTree }
-            })
-        })
-    });
-});
diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js
index c6816a21a38..64d2492acdf 100644
--- a/resources/assets/javascripts/bootstrap/vue.js
+++ b/resources/assets/javascripts/bootstrap/vue.js
@@ -1,46 +1,66 @@
-/**
- * The following block of code is used to automatically register your
- * Vue components. It will recursively scan this directory for the Vue
- * components and automatically register them with their "basename".
- *
- * Eg. ./components/ExampleComponent.vue -> <example-component></example-component>
- */
 STUDIP.ready(() => {
-    $('[data-vue-app]').each(function () {
-        if ($(this).is('[data-vue-app-created]')) {
-            return;
-        }
-
-        const config = Object.assign({}, {
-            id: false,
-            components: [],
-            store: false
-        }, $(this).data().vueApp);
-
-        let data = {};
-        if (config.id && window.STUDIP.AppData && window.STUDIP.AppData[config.id] !== undefined) {
-            data = window.STUDIP.AppData[config.id];
-        }
+    document.querySelectorAll('[data-vue-app]:not([data-vue-app-created])').forEach((node) => {
+        const config = Object.assign(
+            {
+                components: [],
+                stores: {}
+            },
+            JSON.parse(node.dataset.vueApp)
+        );
 
         let components = {};
         config.components.forEach(component => {
-            components[component] = () => import(`../../../vue/components/${component}.vue`);
+            const name = component.split('/').reverse()[0];
+            components[name] = () => import(`../../../vue/components/${component}.vue`);
         });
 
         STUDIP.Vue.load().then(async ({createApp, store}) => {
-            if (config.store) {
-                const storeConfig = await import(`../../../vue/store/${config.store}.js`);
+            for (const [index, name] of Object.entries(config.stores)) {
+                import(`../../../vue/store/${name}.js`).then(storeConfig => {
+                    store.registerModule(index, storeConfig.default);
 
-                store.registerModule(config.id, storeConfig.default, {root: true});
+                    const dataElement = document.getElementById(`vue-store-data-${index}`);
+                    if (dataElement) {
+                        const data = JSON.parse(dataElement.innerText);
+                        Object.keys(data).forEach(command => {
+                            store.commit(`${index}/${command}`, data[command]);
+                        });
 
-                Object.keys(data).forEach(command => {
-                    store.commit(`${config.id}/${command}`, data[command]);
+                        dataElement.remove();
+                    }
                 });
             }
+            createApp({
+                components,
+                store,
 
-            createApp({components, data}).$mount(this);
+                beforeCreate() {
+                    STUDIP.Vue.emit('VueAppWillCreate', this);
+                },
+                created() {
+                    STUDIP.Vue.emit('VueAppDidCreate', this);
+                },
+                beforeMount() {
+                    STUDIP.Vue.emit('VueAppWillMount', this);
+                },
+                mounted() {
+                    STUDIP.Vue.emit('VueAppDidMount', this);
+                },
+                beforeUpdate() {
+                    STUDIP.Vue.emit('VueAppWillUpdate', this);
+                },
+                updated() {
+                    STUDIP.Vue.emit('VueAppDidUpdate', this);
+                },
+                beforeDestroy() {
+                    STUDIP.Vue.emit('VueAppWillDestroy', this);
+                },
+                destroyed() {
+                    STUDIP.Vue.emit('VueAppDidDestroy', this);
+                },
+            }).$mount(node);
         });
 
-        $(this).attr('data-vue-app-created', '');
+        node.dataset.vueAppCreated = 'true';
     });
 });
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index f551c6dadbb..3c2223e0255 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -16,10 +16,6 @@ import "./init.js"
 import "./bootstrap/responsive.js"
 import "./bootstrap/vue.js"
 
-import "./bootstrap/system-notifications.js"
-
-import "./bootstrap/my-courses.js";
-
 import "./studip-ui.js"
 import "./bootstrap/fullscreen.js"
 import "./bootstrap/tfa.js"
@@ -79,12 +75,8 @@ import "./bootstrap/blubber.js"
 import "./bootstrap/consultations.js"
 import "./bootstrap/scroll_to_top.js"
 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"
 import "./bootstrap/external_pages.js"
 
diff --git a/resources/assets/javascripts/lib/admin-courses.js b/resources/assets/javascripts/lib/admin-courses.js
index 23cf6dd3f33..a7bd389f881 100644
--- a/resources/assets/javascripts/lib/admin-courses.js
+++ b/resources/assets/javascripts/lib/admin-courses.js
@@ -1,7 +1,18 @@
 const AdminCourses = {
-    App: null,
+    App: {
+        loadCourse(courseId) {
+            STUDIP.Vue.emit('AdminCourses/loadCourse', courseId);
+        },
+        changeFilter(filters) {
+            STUDIP.Vue.emit('AdminCourses/changeFilter', filters);
+        },
+        changeActionArea(area) {
+            STUDIP.Vue.emit('AdminCourses/changeActionArea', area);
+        }
+    },
     changeFiltersDependendOnInstitute(institut_id) {
-        STUDIP.AdminCourses.App.changeFilter({ institut_id });
+        AdminCourses.App.changeFilter({ institut_id });
+
         //change Studiengangteil filter
         $.get(
             STUDIP.URLHelper.getURL('dispatch.php/admin/courses/get_stdgangteil_selector/' + institut_id)
diff --git a/resources/assets/javascripts/lib/studip-vue.js b/resources/assets/javascripts/lib/studip-vue.js
index c7cf89a2924..6ebd59f4393 100644
--- a/resources/assets/javascripts/lib/studip-vue.js
+++ b/resources/assets/javascripts/lib/studip-vue.js
@@ -1,15 +1,26 @@
-const load = async function () {
-    return STUDIP.loadChunk('vue');
-};
+class Vue
+{
+    static async load()
+    {
+        return STUDIP.loadChunk('vue');
+    }
 
-const on = async function (...args) {
-    const { eventBus } = await load();
-    eventBus.on(...args);
-};
+    static async on(...args)
+    {
+        const { eventBus } = await this.load();
+        eventBus.on(...args);
+    }
 
-const emit = async function (...args) {
-    const { eventBus } = await load();
-    eventBus.emit(...args);
-};
+    static async off(...args) {
+        const { eventBus } = await this.load();
+        eventBus.off(...args);
+}
 
-export default { load, on, emit };
+    static async emit(...args)
+    {
+        const { eventBus } = await this.load();
+        eventBus.emit(...args);
+    }
+}
+
+export default Vue;
diff --git a/resources/vue/components/AdminCourses.vue b/resources/vue/components/AdminCourses.vue
index d24a9004281..2571396fde8 100644
--- a/resources/vue/components/AdminCourses.vue
+++ b/resources/vue/components/AdminCourses.vue
@@ -1,106 +1,109 @@
 <template>
-    <table class="default">
-        <caption>
-            {{ $gettext('Veranstaltungen') }}
-            <span class="actions" v-if="isLoading">
-                <img :src="loadingIndicator" width="20" height="20" :title="$gettext('Daten werden geladen')">
-            </span>
-            <span class="actions" v-else-if="coursesCount > 0">
-                {{ coursesCount + ' ' + $gettext('Veranstaltungen') }}
-            </span>
-        </caption>
-        <thead>
-            <tr class="sortable">
-                <th v-if="showComplete" :class="sort.by === 'completion' ? 'sort' + sort.direction.toLowerCase() : ''">
-                    <a
-                        @click.prevent="changeSort('completion')"
-                        class="course-completion"
-                        :title="$gettext('Bearbeitungsstatus')"
-                    >
-                        {{ $gettext('Bearbeitungsstatus') }}
-                    </a>
-                </th>
-                <th v-for="activeField in sortedActivatedFields" :key="`field-${activeField}`" :class="sort.by === activeField ? 'sort' + sort.direction.toLowerCase() : ''">
-                    <a href="#"
-                       @click.prevent="changeSort(activeField)"
-                       :title="sort.by === activeField && sort.direction === 'ASC' ? $gettextInterpolate('Sortiert aufsteigend nach %{field}', {field: fields[activeField]}, true) : (sort.by === activeField && sort.direction === 'DESC' ? $gettextInterpolate('Sortiert absteigend nach %{ field } ', { field: fields[activeField]}, true) : $gettextInterpolate('Sortieren nach %{ field }', { field: fields[activeField]}, true))"
-                       v-if="!unsortableFields.includes(activeField)"
-                    >
-                        {{ fields[activeField] }}
-                    </a>
-                    <template v-else>
-                        {{ fields[activeField] }}
-                    </template>
-                </th>
-                <th class="actions">
-                    {{ $gettext('Aktion') }}
-                    <studip-action-menu class="filter" :title="$gettext('Darstellungsfilter')" :items="availableFields" @toggleActiveField="toggleActiveField"></studip-action-menu>
-                </th>
-            </tr>
-            <tr v-if="buttons.top">
-                <th v-html="buttons.top" style="text-align: right" :colspan="colspan"></th>
-            </tr>
-        </thead>
-        <tbody :class="{ loading: isLoading }">
-            <tr v-for="course in sortedCourses"
-                :key="course.id"
-                :class="course.id === currentLine ? 'selected' : ''"
-                @click="currentLine = course.id">
-                <td v-if="showComplete">
-                    <button :href="getURL('dispatch.php/admin/courses/toggle_complete/' + course.id)"
-                            class="course-completion undecorated"
-                            :data-course-completion="course.completion"
-                            :title="(course.completion > 0 ? (course.completion == 1 ? $gettext('Veranstaltung in Bearbeitung.') : $gettext('Veranstaltung komplett.')) : $gettext('Veranstaltung neu.')) + ' ' +  $gettext('Klicken zum Ändern des Status.')"
-                            @click.prevent="toggleCompletionState(course.id)">
-                        {{ $gettext('Bearbeitungsstatus ändern') }}
-                    </button>
-                </td>
-                <td v-for="active_field in sortedActivatedFields" :key="active_field">
-                    <div v-html="course[active_field]"></div>
-                    <a v-if="active_field === 'name' && getChildren(course).length > 0"
-                       @click.prevent="toggleOpenChildren(course.id)"
-                       href="">
-                        <studip-icon :shape="open_children.indexOf(course.id) === -1 ? 'add' : 'remove'" class="text-bottom"></studip-icon>
-                        {{ $gettextInterpolate(
-                            $gettext('%{ n } Unterveranstaltungen'),
-                            { n: getChildren(course).length }
-                        ) }}
-                    </a>
-                </td>
-                <td class="actions" v-html="course.action">
-                </td>
-            </tr>
-            <tr v-if="coursesCount === 0 && coursesLoaded">
-                <td :colspan="colspan">
-                    {{ $gettext('Keine Ergebnisse') }}
-                </td>
-            </tr>
-            <tr v-if="coursesCount > 0 && sortedCourses.length === 0">
-                <td :colspan="colspan">
-                    {{
-                        $gettextInterpolate(
-                            $gettext(`%{ n } Veranstaltungen entsprechen Ihrem Filter. Schränken Sie nach Möglichkeit die Filter weiter ein.`),
-                            { n: coursesCount }
-                        )
-                    }}
-                    <a href="" @click.prevent="loadCourses({withoutLimit: true});">
-                        {{ $gettext('Alle anzeigen') }}
-                    </a>
-                </td>
-            </tr>
-            <tr v-if="!coursesLoaded">
-                <td :colspan="colspan">
-                    {{ $gettext('Daten werden geladen ...') }}
-                </td>
-            </tr>
-        </tbody>
-        <tfoot v-if="buttons.bottom">
-            <tr>
-                <td v-html="buttons.bottom" style="text-align: right" :colspan="colspan"></td>
-            </tr>
-        </tfoot>
-    </table>
+    <form method="post">
+        <input type="hidden" :name="csrf.name" :value="csrf.value">
 
+        <table class="default course-admin">
+            <caption>
+                {{ $gettext('Veranstaltungen') }}
+                <span class="actions" v-if="isLoading">
+                    <img :src="loadingIndicator" width="20" height="20" :title="$gettext('Daten werden geladen')">
+                </span>
+                <span class="actions" v-else-if="coursesCount > 0">
+                    {{ coursesCount + ' ' + $gettext('Veranstaltungen') }}
+                </span>
+            </caption>
+            <thead>
+                <tr class="sortable">
+                    <th v-if="showComplete" :class="sort.by === 'completion' ? 'sort' + sort.direction.toLowerCase() : ''">
+                        <a
+                            @click.prevent="changeSort('completion')"
+                            class="course-completion"
+                            :title="$gettext('Bearbeitungsstatus')"
+                        >
+                            {{ $gettext('Bearbeitungsstatus') }}
+                        </a>
+                    </th>
+                    <th v-for="activeField in sortedActivatedFields" :key="`field-${activeField}`" :class="sort.by === activeField ? 'sort' + sort.direction.toLowerCase() : ''">
+                        <a href="#"
+                           @click.prevent="changeSort(activeField)"
+                           :title="sort.by === activeField && sort.direction === 'ASC' ? $gettextInterpolate('Sortiert aufsteigend nach %{field}', {field: fields[activeField]}, true) : (sort.by === activeField && sort.direction === 'DESC' ? $gettextInterpolate('Sortiert absteigend nach %{ field } ', { field: fields[activeField]}, true) : $gettextInterpolate('Sortieren nach %{ field }', { field: fields[activeField]}, true))"
+                           v-if="!unsortableFields.includes(activeField)"
+                        >
+                            {{ fields[activeField] }}
+                        </a>
+                        <template v-else>
+                            {{ fields[activeField] }}
+                        </template>
+                    </th>
+                    <th class="actions">
+                        {{ $gettext('Aktion') }}
+                        <studip-action-menu class="filter" :title="$gettext('Darstellungsfilter')" :items="availableFields" @toggleActiveField="toggleActiveField"></studip-action-menu>
+                    </th>
+                </tr>
+                <tr v-if="buttons.top">
+                    <th v-html="buttons.top" style="text-align: right" :colspan="colspan"></th>
+                </tr>
+            </thead>
+            <tbody :class="{ loading: isLoading }">
+                <tr v-for="course in sortedCourses"
+                    :key="course.id"
+                    :class="course.id === currentLine ? 'selected' : ''"
+                    @click="currentLine = course.id">
+                    <td v-if="showComplete">
+                        <button :href="getURL('dispatch.php/admin/courses/toggle_complete/' + course.id)"
+                                class="course-completion undecorated"
+                                :data-course-completion="course.completion"
+                                :title="(course.completion > 0 ? (course.completion == 1 ? $gettext('Veranstaltung in Bearbeitung.') : $gettext('Veranstaltung komplett.')) : $gettext('Veranstaltung neu.')) + ' ' +  $gettext('Klicken zum Ändern des Status.')"
+                                @click.prevent="toggleCompletionState(course.id)">
+                            {{ $gettext('Bearbeitungsstatus ändern') }}
+                        </button>
+                    </td>
+                    <td v-for="active_field in sortedActivatedFields" :key="active_field">
+                        <div v-html="course[active_field]"></div>
+                        <a v-if="active_field === 'name' && getChildren(course).length > 0"
+                           @click.prevent="toggleOpenChildren(course.id)"
+                           href="">
+                            <studip-icon :shape="open_children.indexOf(course.id) === -1 ? 'add' : 'remove'" class="text-bottom"></studip-icon>
+                            {{ $gettextInterpolate(
+                                $gettext('%{ n } Unterveranstaltungen'),
+                                { n: getChildren(course).length }
+                            ) }}
+                        </a>
+                    </td>
+                    <td class="actions" v-html="course.action">
+                    </td>
+                </tr>
+                <tr v-if="coursesCount === 0 && coursesLoaded">
+                    <td :colspan="colspan">
+                        {{ $gettext('Keine Ergebnisse') }}
+                    </td>
+                </tr>
+                <tr v-if="coursesCount > 0 && sortedCourses.length === 0">
+                    <td :colspan="colspan">
+                        {{
+                            $gettextInterpolate(
+                                $gettext(`%{ n } Veranstaltungen entsprechen Ihrem Filter. Schränken Sie nach Möglichkeit die Filter weiter ein.`),
+                                { n: coursesCount }
+                            )
+                        }}
+                        <a href="" @click.prevent="loadCourses({withoutLimit: true});">
+                            {{ $gettext('Alle anzeigen') }}
+                        </a>
+                    </td>
+                </tr>
+                <tr v-if="!coursesLoaded">
+                    <td :colspan="colspan">
+                        {{ $gettext('Daten werden geladen ...') }}
+                    </td>
+                </tr>
+            </tbody>
+            <tfoot v-if="buttons.bottom">
+                <tr>
+                    <td v-html="buttons.bottom" style="text-align: right" :colspan="colspan"></td>
+                </tr>
+            </tfoot>
+        </table>
+    </form>
 </template>
 <script>
 import { mapActions, mapGetters, mapState } from 'vuex';
@@ -130,6 +133,15 @@ export default {
     },
     created() {
         this.loadCourses();
+
+        this.globalOn('AdminCourses/changeActionArea', this.changeActionArea.bind(this));
+        this.globalOn('AdminCourses/changeFilter', this.changeFilter.bind(this));
+        this.globalOn('AdminCourses/loadCourse', this.loadCourse.bind(this));
+    },
+    destroyed() {
+        this.globalOff('AdminCourses/changeActionArea', this.changeActionArea.bind(this));
+        this.globalOff('AdminCourses/changeFilter', this.changeFilter.bind(this));
+        this.globalOff('AdminCourses/loadCourse', this.loadCourse.bind(this));
     },
     computed: {
         ...mapState('admincourses', [
@@ -143,6 +155,9 @@ export default {
         ...mapGetters('admincourses', [
             'isLoading',
         ]),
+        csrf() {
+            return STUDIP.CSRF_TOKEN;
+        },
         colspan () {
             let colspan = this.activatedFields.length + 1;
             if (this.showComplete) {
@@ -284,6 +299,41 @@ export default {
         getURL(url, params = {}) {
             return STUDIP.URLHelper.getURL(url, params);
         },
-    }
+    },
 };
 </script>
+<style lang="scss">
+@import '../../assets/stylesheets/mixins.scss';
+
+.course-admin {
+    .course-completion {
+        @include hide-text();
+        @include square(16px);
+        background-repeat: no-repeat;
+        display: block;
+    }
+
+    th .course-completion {
+        @include background-icon(radiobutton-checked, clickable);
+    }
+
+    td .course-completion {
+        @include background-icon(span-empty, status-red);
+
+        &[data-course-completion="1"] {
+            @include background-icon(span-2quarter, status-yellow);
+        }
+        &[data-course-completion="2"] {
+            @include background-icon(span-full, status-green);
+        }
+
+        &.ajaxing {
+            background-image: url("#{$image-path}/loading-indicator.svg");
+        }
+    }
+    > tbody.loading > tr > td {
+        opacity: 0.5;
+    }
+}
+
+</style>
diff --git a/resources/vue/components/CacheAdministration.vue b/resources/vue/components/CacheAdministration.vue
index 5da9568d415..a7a1792357d 100644
--- a/resources/vue/components/CacheAdministration.vue
+++ b/resources/vue/components/CacheAdministration.vue
@@ -1,5 +1,8 @@
 <template>
-    <form class="default" :action="actionUrl" method="post" ref="configForm">
+    <StudipMessageBox v-if="!enabled" type="warning" :hide-close="true">
+        {{ $gettext('Caching ist systemweit ausgeschaltet, daher kann hier nichts konfiguriert werden.') }}
+    </StudipMessageBox>
+    <form v-else class="default" :action="actionUrl" method="post" ref="configForm">
         <fieldset>
             <legend>
                 <translate>Cachetyp</translate>
@@ -36,10 +39,12 @@
 import FileCacheConfig from './FileCacheConfig.vue'
 import MemcachedCacheConfig from './MemcachedCacheConfig.vue'
 import RedisCacheConfig from './RedisCacheConfig.vue'
+import StudipMessageBox from './StudipMessageBox.vue';
 
 export default {
     name: 'CacheAdministration',
     components: {
+        StudipMessageBox,
         FileCacheConfig,
         MemcachedCacheConfig,
         RedisCacheConfig
@@ -61,6 +66,10 @@ export default {
                     props: []
                 };
             }
+        },
+        enabled: {
+            type: Boolean,
+            required: true,
         }
     },
     data () {
diff --git a/templates/header.php b/templates/header.php
index 711d51785d2..1be47abd629 100644
--- a/templates/header.php
+++ b/templates/header.php
@@ -66,14 +66,14 @@ if ($navigation) {
                     'username' => $user->username,
                     'perm' => $GLOBALS['perm']->get_perm()
                 ];
-                ?>
-            <? } else {
+            } else {
                 $me = ['username' => 'nobody'];
             } ?>
-            <responsive-navigation :me="<?= htmlReady(json_encode($me)) ?>"
-                                   context="<?= htmlReady(Context::get() ? Context::get()->getFullName() : '') ?>"
-                                   :navigation="<?= htmlReady(json_encode(ResponsiveHelper::getNavigationObject($_COOKIE['responsive-navigation-hash'] ?? null))) ?>"
-            ></responsive-navigation>
+            <?= Studip\VueApp::create('responsive/ResponsiveNavigation')->withProps([
+                'context' => Context::get()?->getFullName() ?? '',
+                'me' => $me,
+                'navigation' => ResponsiveHelper::getNavigationObject($_COOKIE['responsive-navigation-hash'] ?? null),
+            ]) ?>
         </div>
         <div id="site-title">
             <?= htmlReady(Config::get()->UNI_NAME_CLEAN) ?>
diff --git a/templates/vue-app.php b/templates/vue-app.php
new file mode 100644
index 00000000000..2c34929f9bd
--- /dev/null
+++ b/templates/vue-app.php
@@ -0,0 +1,14 @@
+<?php
+/**
+ * @var array $attributes
+ * @var string $baseComponent
+ * @var array $props
+ * @var array $storeData
+ */
+?>
+<? foreach ($storeData as $store => $data): ?>
+<script type="application/json" id="vue-store-data-<?= htmlReady($store) ?>"><?= json_encode($data) ?></script>
+<? endforeach; ?>
+<div <?= arrayToHtmlAttributes($attributes) ?>>
+    <<?= strtokebabcase($baseComponent) ?> <?= arrayToHtmlAttributes($props) ?>/>
+</div>
-- 
GitLab