From 559ab723fabd4d10f26e7df631808e4cb8d91c9b Mon Sep 17 00:00:00 2001
From: Thomas Hackl <hackl@data-quest.de>
Date: Wed, 28 Jun 2023 13:27:46 +0000
Subject: [PATCH] Resolve "Neuentwicklung Verzeichnisstrukturen"

Closes #1664, #2693, and #2692

Merge request studip/studip!1081
---
 app/controllers/admin/courses.php             |   8 +
 app/controllers/admin/tree.php                | 292 +++++++++++++
 app/controllers/search/courses.php            | 143 +++----
 app/controllers/studyarea.php                 |  51 +++
 app/controllers/tree.php                      |  55 +++
 .../admin/courses/batch_assign_semtree.php    |   8 +
 app/views/admin/courses/courses.php           |   3 +-
 app/views/admin/tree/assign_courses.php       |  10 +
 app/views/admin/tree/batch_assign_semtree.php |  43 ++
 app/views/admin/tree/create.php               |  49 +++
 app/views/admin/tree/edit.php                 |  54 +++
 app/views/admin/tree/rangetree.php            |   9 +
 app/views/admin/tree/semtree.php              |  10 +
 app/views/search/courses/index.php            |  14 +-
 app/views/studyarea/edit.php                  |   1 +
 db/migrations/5.4.6_tree_changes.php          |  66 +++
 lib/classes/JsonApi/RouteMap.php              |  12 +
 .../RangeTree/ChildrenOfRangeTreeNode.php     |  39 ++
 .../RangeTree/CoursesOfRangeTreeNode.php      |  51 +++
 .../RangeTree/InstituteOfRangeTreeNode.php    |  30 ++
 .../RangeTree/ParentOfRangeTreeNode.php       |  32 ++
 .../Routes/RangeTree/RangeTreeIndex.php       |  53 +++
 .../Routes/RangeTree/RangeTreeShow.php        |  32 ++
 .../Routes/StudyAreas/StudyAreasShow.php      |   3 +-
 .../Routes/Tree/ChildrenOfTreeNode.php        |  50 +++
 .../Routes/Tree/CourseInfoOfTreeNode.php      |  83 ++++
 .../JsonApi/Routes/Tree/CoursesOfTreeNode.php | 112 +++++
 .../Routes/Tree/DetailsOfTreeNodeCourse.php   |  79 ++++
 .../Routes/Tree/PathinfoOfTreeNodeCourse.php  |  34 ++
 lib/classes/JsonApi/Routes/Tree/TreeShow.php  |  40 ++
 lib/classes/JsonApi/SchemaMap.php             |   2 +-
 lib/classes/JsonApi/Schemas/StudyArea.php     |   4 +-
 lib/classes/JsonApi/Schemas/TreeNode.php      | 151 +++++++
 lib/classes/SemBrowse.class.php               |  10 +-
 lib/classes/StudipTreeNode.php                | 114 ++++++
 lib/classes/forms/QuicksearchInput.php        |   2 +-
 lib/classes/searchtypes/TreeSearch.class.php  |  96 +++++
 lib/models/RangeTreeNode.php                  | 263 ++++++++++++
 lib/models/StudipStudyArea.class.php          | 218 ++++++++--
 lib/navigation/AdminNavigation.php            |   4 +-
 public/admin_range_tree.php                   |  59 ---
 public/admin_sem_tree.php                     | 310 --------------
 .../assets/javascripts/bootstrap/treeview.js  |  12 +
 resources/assets/javascripts/entry-base.js    |   1 +
 .../assets/stylesheets/scss/sidebar.scss      |   2 +-
 resources/assets/stylesheets/scss/tree.scss   | 384 ++++++++++++++++++
 resources/assets/stylesheets/studip.scss      |   1 +
 resources/vue/components/SearchWidget.vue     |  56 +++
 .../components/StudipProgressIndicator.vue    |   2 +-
 .../vue/components/tree/AssignLinkWidget.vue  |  46 +++
 resources/vue/components/tree/StudipTree.vue  | 214 ++++++++++
 .../vue/components/tree/StudipTreeList.vue    | 359 ++++++++++++++++
 .../vue/components/tree/StudipTreeNode.vue    | 277 +++++++++++++
 .../vue/components/tree/StudipTreeTable.vue   | 377 +++++++++++++++++
 .../vue/components/tree/TreeBreadcrumb.vue    | 194 +++++++++
 .../vue/components/tree/TreeCourseDetails.vue |  51 +++
 .../vue/components/tree/TreeExportWidget.vue  |  50 +++
 .../components/tree/TreeNodeCourseInfo.vue    |  76 ++++
 .../components/tree/TreeNodeCoursePath.vue    |  54 +++
 .../vue/components/tree/TreeNodeTile.vue      |  46 +++
 .../vue/components/tree/TreeSearchResult.vue  |  85 ++++
 resources/vue/mixins/TreeMixin.js             | 108 +++++
 62 files changed, 4581 insertions(+), 513 deletions(-)
 create mode 100644 app/controllers/admin/tree.php
 create mode 100644 app/controllers/studyarea.php
 create mode 100644 app/controllers/tree.php
 create mode 100644 app/views/admin/courses/batch_assign_semtree.php
 create mode 100644 app/views/admin/tree/assign_courses.php
 create mode 100644 app/views/admin/tree/batch_assign_semtree.php
 create mode 100644 app/views/admin/tree/create.php
 create mode 100644 app/views/admin/tree/edit.php
 create mode 100644 app/views/admin/tree/rangetree.php
 create mode 100644 app/views/admin/tree/semtree.php
 create mode 100644 app/views/studyarea/edit.php
 create mode 100644 db/migrations/5.4.6_tree_changes.php
 create mode 100644 lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php
 create mode 100644 lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php
 create mode 100644 lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php
 create mode 100644 lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php
 create mode 100644 lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php
 create mode 100644 lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php
 create mode 100644 lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php
 create mode 100644 lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php
 create mode 100644 lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php
 create mode 100644 lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php
 create mode 100644 lib/classes/JsonApi/Routes/Tree/TreeShow.php
 create mode 100644 lib/classes/JsonApi/Schemas/TreeNode.php
 create mode 100644 lib/classes/StudipTreeNode.php
 create mode 100644 lib/classes/searchtypes/TreeSearch.class.php
 create mode 100644 lib/models/RangeTreeNode.php
 delete mode 100644 public/admin_range_tree.php
 delete mode 100644 public/admin_sem_tree.php
 create mode 100644 resources/assets/javascripts/bootstrap/treeview.js
 create mode 100644 resources/assets/stylesheets/scss/tree.scss
 create mode 100644 resources/vue/components/SearchWidget.vue
 create mode 100644 resources/vue/components/tree/AssignLinkWidget.vue
 create mode 100644 resources/vue/components/tree/StudipTree.vue
 create mode 100644 resources/vue/components/tree/StudipTreeList.vue
 create mode 100644 resources/vue/components/tree/StudipTreeNode.vue
 create mode 100644 resources/vue/components/tree/StudipTreeTable.vue
 create mode 100644 resources/vue/components/tree/TreeBreadcrumb.vue
 create mode 100644 resources/vue/components/tree/TreeCourseDetails.vue
 create mode 100644 resources/vue/components/tree/TreeExportWidget.vue
 create mode 100644 resources/vue/components/tree/TreeNodeCourseInfo.vue
 create mode 100644 resources/vue/components/tree/TreeNodeCoursePath.vue
 create mode 100644 resources/vue/components/tree/TreeNodeTile.vue
 create mode 100644 resources/vue/components/tree/TreeSearchResult.vue
 create mode 100644 resources/vue/mixins/TreeMixin.js

diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php
index a683639e416..71b75ce7aff 100644
--- a/app/controllers/admin/courses.php
+++ b/app/controllers/admin/courses.php
@@ -1042,6 +1042,14 @@ class Admin_CoursesController extends AuthenticatedController
                 'attributes' => ['data-dialog' => 'size=auto'],
                 'partial'    => 'notice-action.php',
             ],
+            21 => [
+                'name'       => _('Mehrfachzuordnung von Studienbereichen'),
+                'title'      => _('Mehrfachzuordnung von Studienbereichen'),
+                'url'        => 'dispatch.php/admin/tree/batch_assign_semtree',
+                'dialogform' => true,
+                'multimode'  => true,
+                'partial'    => 'batch_assign_semtree.php'
+            ],
         ];
 
         if (!$GLOBALS['perm']->have_perm('admin')) {
diff --git a/app/controllers/admin/tree.php b/app/controllers/admin/tree.php
new file mode 100644
index 00000000000..18ddb065277
--- /dev/null
+++ b/app/controllers/admin/tree.php
@@ -0,0 +1,292 @@
+<?php
+
+class Admin_TreeController extends AuthenticatedController
+{
+    public function rangetree_action()
+    {
+        $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();
+    }
+
+    public function semtree_action()
+    {
+        $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();
+    }
+
+    /**
+     * Edit the given node.
+     *
+     * @param string $class_id concatenated classname and node id
+     * @return void
+     */
+    public function edit_action(string $class_id)
+    {
+        $GLOBALS['perm']->check('root');
+        PageLayout::setTitle(_('Eintrag bearbeiten'));
+
+        $data = $this->checkClassAndId($class_id);
+        $this->node = $data['classname']::getNode($data['id']);
+        $parent = $data['classname']::getNode($this->node->parent_id);
+
+        $this->treesearch = QuickSearch::get(
+            'parent_id',
+            new TreeSearch($data['classname'] === StudipStudyArea::class ? 'sem_tree_id' : 'range_tree_id')
+        )->withButton();
+        $this->treesearch->defaultValue($parent->id, $parent->getName());
+
+        if ($data['classname'] === RangeTreeNode::class) {
+            $this->instsearch = QuickSearch::get(
+                'studip_object_id',
+                new StandardSearch('Institut_id')
+            )->withButton();
+            if ($this->node->studip_object_id) {
+                $this->instsearch->defaultValue($this->node->studip_object_id, $this->node->institute->name);
+            }
+        }
+
+        $this->from = Request::get('from');
+    }
+
+    /**
+     * Create a new child node of the given parent.
+     *
+     * @param string $class_id concatenated classname and parent id
+     * @return void
+     */
+    public function create_action(string $class_id)
+    {
+        $GLOBALS['perm']->check('root');
+        PageLayout::setTitle(_('Neuen Eintrag anlegen'));
+
+        $data = $this->checkClassAndId($class_id);
+
+        $this->node = new $data['classname']();
+        $this->node->parent_id = $data['id'];
+        $parent = $data['classname']::getNode($data['id']);
+
+        $this->treesearch = QuickSearch::get(
+            'parent_id',
+            new TreeSearch(get_class($this->node) === StudipStudyArea::class ? 'sem_tree_id' : 'range_tree_id')
+        )->withButton();
+        $this->treesearch->defaultValue($parent->id, $parent->getName());
+
+        $this->instsearch = QuickSearch::get(
+            'studip_object_id',
+            new StandardSearch('Institut_id')
+        )->withButton();
+
+        $this->from = Request::get('from');
+    }
+
+    /**
+     * Delete the given child node.
+     *
+     * @param string $class_id concatenated classname and node id
+     * @return void
+     */
+    public function delete_action(string $class_id)
+    {
+        $GLOBALS['perm']->check('root');
+        $data = $this->checkClassAndId($class_id);
+
+        if (!Request::isPost()) {
+            throw new MethodNotAllowedException();
+        }
+        $node = $data['classname']::getNode($data['id']);
+
+        if ($node) {
+            $node->delete();
+        } else {
+            $this->set_status(404);
+        }
+
+        $this->render_nothing();
+    }
+
+    /**
+     * Store the given node.
+     *
+     * @param string $classname
+     * @param string $node_id
+     * @return void
+     */
+    public function store_action(string $classname, string $node_id = '')
+    {
+        $GLOBALS['perm']->check('root');
+        CSRFProtection::verifyUnsafeRequest();
+
+        $node = new $classname($node_id);
+        $node->parent_id = Request::option('parent_id');
+
+        $parent = $classname::getNode(Request::option('parent_id'));
+        $maxprio = max(array_map(
+            function ($c) {
+                return $c->priority;
+            },
+            $parent->getChildNodes()
+        ));
+        $node->priority = $maxprio + 1;
+
+        if (Request::option('studip_object_id')) {
+            $node->studip_object_id = Request::option('studip_object_id');
+            $node->name = '';
+        } else {
+            $node->name = Request::get('name');
+        }
+
+        if ($classname === StudipStudyArea::class) {
+            $node->info = Request::get('description');
+            $node->type = Request::int('type');
+        }
+
+        if ($node->store() !== false) {
+            Pagelayout::postSuccess(_('Die Daten wurden gespeichert.'));
+        } else {
+            Pagelayout::postError(_('Die Daten konnten nicht gespeichert werden.'));
+        }
+
+        $this->relocate(Request::get('from'));
+    }
+
+    public function sort_action($parent_id)
+    {
+        $GLOBALS['perm']->check('root');
+        $data = $this->checkClassAndId($parent_id);
+
+        $parent = $data['classname']::getNode($data['id']);
+        $children = $parent->getChildNodes();
+
+        $data = json_decode(Request::get('sorting'), true);
+
+        foreach ($children as $child) {
+            $child->priority = $data[$child->id];
+            $child->store();
+        }
+
+        $this->render_nothing();
+    }
+
+    /**
+     * (De-)assign several courses at once to a sem_tree node
+     * @return void
+     * @throws Exception
+     */
+    public function batch_assign_semtree_action()
+    {
+        $GLOBALS['perm']->check('admin');
+        //set the page title with the area of Stud.IP:
+        PageLayout::setTitle(_('Veranstaltungszuordnungen bearbeiten'));
+        Navigation::activateItem('/browse/my_courses/list');
+
+        $GLOBALS['perm']->check('admin');
+
+        // check the assign_semtree array and extract the relevant course IDs:
+        $courseIds = Request::optionArray('assign_semtree');
+
+        $order = Config::get()->IMPORTANT_SEMNUMBER
+            ? "ORDER BY `start_time` DESC, `VeranstaltungsNummer`, `Name`"
+            : "ORDER BY `start_time` DESC,  `Name`";
+        $this->courses = Course::findMany($courseIds, $order);
+
+        $this->return = Request::get('return');
+
+        // check if at least one course was selected (this can only happen from admin courses overview):
+        if (!$courseIds) {
+            PageLayout::postWarning('Es wurde keine Veranstaltung gewählt.');
+            $this->relocate('admin/courses');
+        }
+    }
+
+    public function assign_courses_action($class_id)
+    {
+        $GLOBALS['perm']->check('root');
+        $data = $this->checkClassAndId($class_id);
+        $GLOBALS['perm']->check('admin');
+
+        $this->search = QuickSearch::get('courses[]', new StandardSearch('Seminar_id'))->withButton();
+        $this->node = $data['id'];
+    }
+
+    /**
+     * Store (de-)assignments from courses to sem_tree nodes.
+     * @return void
+     */
+    public function do_batch_assign_action()
+    {
+        $GLOBALS['perm']->check('admin');
+        $astmt = DBManager::get()->prepare("INSERT IGNORE INTO `seminar_sem_tree` VALUES (:course, :node)");
+        $dstmt = DBManager::get()->prepare(
+            "DELETE FROM `seminar_sem_tree` WHERE `seminar_id` IN (:courses) AND `sem_tree_id` = :node");
+
+        $success = true;
+        // Add course assignments to the specified nodes.
+        foreach (Request::optionArray('courses') as $course) {
+            foreach (Request::optionArray('add_assignments') as $a) {
+                $success = $astmt->execute(['course' => $course, 'node' => $a]);
+            }
+        }
+
+        // Remove course assignments from the specified nodes.
+        foreach (Request::optionArray('delete_assignments') as $d) {
+            $success = $dstmt->execute(['courses' => Request::optionArray('courses'), 'node' => $d]);
+        }
+
+        if ($success) {
+            PageLayout::postSuccess(_('Die Zuordnungen wurden gespeichert.'));
+        } else {
+            PageLayout::postError(_('Die Zuordnungen konnten nicht vollständig gespeichert werden.'));
+        }
+
+        $this->relocate(Request::get('return', 'admin/courses'));
+    }
+
+    private function setupSidebar()
+    {
+        $sidebar = Sidebar::Get();
+
+        $semWidget = new SemesterSelectorWidget($this->url_for(''), 'semester');
+        $semWidget->includeAll(true);
+        $semWidget->setId('semester-selector');
+        $semWidget->setSelection($this->semester);
+        $sidebar->addWidget($semWidget);
+
+        if ($this->classname === StudipStudyArea::class) {
+            $sidebar->addWidget(new VueWidget('assign-widget'));
+        }
+    }
+
+    /**
+     * CHeck a combination of class name and ID for validity: is this a StudipTreeNode subclass?
+     * If yes, return the corresponding object.
+     *
+     * @param string $class_id class name and ID, separated by '_'
+     * @return mixed
+     */
+    private function checkClassAndId($class_id)
+    {
+        list($classname, $id) = explode('_', $class_id);
+
+        if (is_a($classname, StudipTreeNode::class, true)) {
+            return [
+                'classname' => $classname,
+                'id' => $id
+            ];
+        }
+
+        throw new InvalidArgumentException(
+            sprintf('The given class "%s" does not implement the StudipTreeNode interface!', $classname)
+        );
+
+    }
+}
diff --git a/app/controllers/search/courses.php b/app/controllers/search/courses.php
index 536c361ae41..71b7f7d689a 100644
--- a/app/controllers/search/courses.php
+++ b/app/controllers/search/courses.php
@@ -27,108 +27,30 @@ class Search_CoursesController extends AuthenticatedController
 
         PageLayout::setHelpKeyword('Basis.VeranstaltungenAbonnieren');
 
-        // activate navigation item
-        $nav_options = Config::get()->COURSE_SEARCH_NAVIGATION_OPTIONS;
-        URLHelper::bindLinkParam('option', $this->nav_option);
-        if (!empty($nav_options[$this->nav_option])
-                && Navigation::hasItem('/search/courses/' . $this->nav_option)) {
-            Navigation::activateItem('/search/courses/' . $this->nav_option);
-        } else {
-            URLHelper::removeLinkParam('option');
-            $level = Request::get('level', $_SESSION['sem_browse_data']['level'] ?? '');
-            $default_option = SemBrowse::getSearchOptionNavigation('sidebar');
-            if (!$level) {
-                PageLayout::setTitle(_($default_option->getTitle()));
-                $this->relocate($default_option->getURL());
-            } elseif ($level == 'f' && $nav_options['courses']['visible']) {
-                $course_option = SemBrowse::getSearchOptionNavigation('sidebar','courses');
-                PageLayout::setTitle(_($course_option->getTitle()));
-                Navigation::activateItem('/search/courses/semtree');
-            } elseif (($level == 'vv') && $nav_options['semtree']['visible']) {
-                $semtree_option = SemBrowse::getSearchOptionNavigation('sidebar','semtree');
-                PageLayout::setTitle(_($semtree_option->getTitle()));
-                Navigation::activateItem('/search/courses/semtree');
-            } elseif ($level == 'ev' && $nav_options['rangetree']['visible']) {
-                $rangetree_option = SemBrowse::getSearchOptionNavigation('sidebar','rangetree');
-                PageLayout::setTitle(_($rangetree_option->getTitle()));
-                Navigation::activateItem('/search/courses/rangetree');
-            } else {
-                throw new AccessDeniedException();
-            }
-        }
+        $this->type = Request::option('type', 'semtree');
+        $this->semester = Request::option('semester', Semester::findCurrent()->id);
+        $this->semClass = Request::int('semclass', 0);
     }
 
     public function index_action()
     {
-        SemBrowse::transferSessionData();
-        $this->sem_browse_obj = new SemBrowse();
-
-        if (!$GLOBALS['perm']->have_perm('root')) {
-            $this->sem_browse_obj->target_url = 'dispatch.php/course/details/';
-            $this->sem_browse_obj->target_id = 'sem_id';
-        } else {
-            $this->sem_browse_obj->target_url = 'seminar_main.php';
-            $this->sem_browse_obj->target_id = 'auswahl';
-        }
-
-        $sidebar = Sidebar::get();
-
-        // add search options to sidebar
-        $level = Request::get('level', $_SESSION['sem_browse_data']['level'] ?? '');
-
-        $widget = new OptionsWidget();
-        $widget->setTitle(_('Suche'));
-        //add a quicksearch input inside the widget
-        $search_content = $this->sem_browse_obj->getQuickSearchForm();
-        $search_element = new WidgetElement($search_content);
-        $widget->addElement($search_element);
-        $widget->addCheckbox(_('Erweiterte Suche anzeigen'),
-        $_SESSION['sem_browse_data']['cmd'] == "xts",
-        URLHelper::getURL('?level='.$level.'&cmd=xts&sset=0&option='),
-        URLHelper::getURL('?level='.$level.'&cmd=qs&sset=0&option='));
-        $sidebar->addWidget($widget);
-
-        SemBrowse::setSemesterSelector($this->url_for('search/courses/index'));
-        SemBrowse::setClassesSelector($this->url_for('search/courses/index'));
-
-
-        if ($this->sem_browse_obj->show_result
-                && count($_SESSION['sem_browse_data']['search_result'])) {
-            $actions = new ActionsWidget();
-            $actions->addLink(_('Download des Ergebnisses'),
-                    URLHelper::getURL('dispatch.php/search/courses/export_results'),
-                    Icon::create('file-office', 'clickable'));
-            $sidebar->addWidget($actions);
-
-            $grouping = new OptionsWidget();
-            $grouping->setTitle(_('Suchergebnis gruppieren:'));
-            foreach ($this->sem_browse_obj->group_by_fields as $i => $field) {
-                $grouping->addRadioButton(
-                    $field['name'],
-                    URLHelper::getURL('?', ['group_by' => $i,
-                        'keep_result_set' => 1]),
-                    $_SESSION['sem_browse_data']['group_by'] == $i
-                );
-            }
-            $sidebar->addWidget($grouping);
-        }
-
-        // show information about course class if class was changed
-        $class = $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']] ?? null;
-        if (is_object($class) && $class->countSeminars() > 0) {
-            if (trim($GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['description'])) {
-                PageLayout::postInfo(sprintf(_('Gewählte Veranstaltungsklasse <i>%1s</i>: %2s'),
-                        $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['name'],
-                        $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['description']));
-            } else {
-                PageLayout::postInfo(sprintf(_('Gewählte Veranstaltungsklasse <i>%1s</i>.'),
-                        $GLOBALS['SEM_CLASS'][$_SESSION['sem_browse_data']['show_class']]['name']));
-            }
-        } elseif ($_SESSION['sem_browse_data']['show_class'] != 'all') {
-            PageLayout::postInfo(_('Im gewählten Semester ist in dieser Veranstaltungsklasse keine Veranstaltung verfügbar. Bitte wählen Sie eine andere Veranstaltungsklasse oder ein anderes Semester!'));
+        $nodeClass = '';
+        if (Request::option('type', 'semtree') === 'semtree') {
+            Navigation::activateItem('/search/courses/semtree');
+            $nodeClass = StudipStudyArea::class;
+            $this->treeTitle = _('Studienbereiche');
+            $this->breadcrumbIcon = 'literature';
+            $this->editUrl = $this->url_for('studyarea/edit');
+        } else if (Request::option('type', 'semtree') === 'rangetree') {
+            Navigation::activateItem('/search/courses/rangetree');
+            $nodeClass = RangeTreeNode::class;
+            $this->treeTitle = _('Einrichtungen');
+            $this->breadcrumbIcon = 'institute';
+            $this->editUrl = $this->url_for('rangetree/edit');
         }
+        $this->startId = Request::option('node_id', $nodeClass . '_root');
 
-        $this->controller = $this;
+        $this->setupSidebar();
     }
 
     public function export_results_action()
@@ -143,4 +65,33 @@ class Search_CoursesController extends AuthenticatedController
         }
     }
 
+    private function setupSidebar()
+    {
+        $sidebar = Sidebar::Get();
+
+        $semWidget = new SemesterSelectorWidget($this->url_for(''), 'semester');
+        $semWidget->includeAll(false);
+        $semWidget->setId('semester-selector');
+        $semWidget->setSelection($this->semester);
+        $sidebar->addWidget($semWidget);
+
+        $classWidget = $sidebar->addWidget(new SelectWidget(
+            _('Veranstaltungskategorie'),
+            URLHelper::getURL('', ['type' => $this->type, 'semester' => $this->semester]),
+            'semclass'
+        ));
+        $classWidget->addElement(new SelectElement(0, _('Alle')));
+        foreach (SemClass::getClasses() as $class) {
+            if (!$class['studygroup_mode']) {
+                $classWidget->addElement(new SelectElement(
+                    $class['id'],
+                    $class['name'],
+                    $this->semClass == $class['id']
+                ));
+            }
+        }
+
+        $sidebar->addWidget(new VueWidget('search-widget'));
+        $sidebar->addWidget(new VueWidget('export-widget'));
+    }
 }
diff --git a/app/controllers/studyarea.php b/app/controllers/studyarea.php
new file mode 100644
index 00000000000..e43c9b0af15
--- /dev/null
+++ b/app/controllers/studyarea.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * treenode.php - Controller for editing tree nodes
+ *
+ * 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.
+ *
+ * @author      Thomas Hackl <hackl@data-quest.de>
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 or later
+ * @category    Stud.IP
+ * @since       5.4
+ */
+
+class StudyareaController extends AuthenticatedController
+{
+    public function edit_action($id = '')
+    {
+        if ($id !== '') {
+            $object = StudipStudyArea::find($id);
+        } else {
+            $object = new StudipStudyArea();
+        }
+
+        PageLayout::setTitle($object->isNew() ? _('Studienbereich anlegen') : _('Studienbereich bearbeiten'));
+
+        $this->form = Studip\Forms\Form::fromSORM(
+            $object,
+            [
+                'legend' => $object->isNew()
+                    ? _('Neuer Studienbereich')
+                    : sprintf(_('Studienbereich %s'), $object->name),
+                'text' => ['text' => ''],
+                'fields' => [
+                    'name' => [
+                        'label' => _('Name'),
+                        'type' => 'text',
+                        'required' => true
+                    ],
+                    'info' => [
+                        'label' => _('Beschreibung'),
+                        'type' => 'textarea'
+                    ]
+                ]
+            ]
+        )->setURL($this->url_for('studyarea/store', $object->id));
+    }
+
+}
diff --git a/app/controllers/tree.php b/app/controllers/tree.php
new file mode 100644
index 00000000000..7665b60c10a
--- /dev/null
+++ b/app/controllers/tree.php
@@ -0,0 +1,55 @@
+<?php
+
+class TreeController extends AuthenticatedController
+{
+    public function export_csv_action()
+    {
+        if (!Request::isPost()) {
+            throw new MethodNotAllowedException();
+        }
+
+        $ids = explode(',', Request::get('courses', ''));
+        $courses = Course::findMany($ids);
+
+        $captions = [
+            _('Veranstaltungsnummer'),
+            _('Name'),
+            _('Semester'),
+            _('Zeiten'),
+            _('Lehrende')
+        ];
+
+        $data = [];
+        foreach ($courses as $course) {
+            $sem = Seminar::getInstance($course->id);
+            $lecturers = SimpleCollection::createFromArray(
+                CourseMember::findByCourseAndStatus($course->id, 'dozent')
+            )->orderBy('position, nachname, vorname');
+
+            $lecturersSorted = array_map(
+                function ($l) {
+                    return implode(', ', $l);
+                },
+                $lecturers->toArray('nachname vorname title_front title_rear')
+            );
+
+            $data[] = [
+                $course->veranstaltungsnummer,
+                $course->getFullname('type-number-name'),
+                $course->getTextualSemester(),
+                $sem->getDatesExport(),
+                implode(', ', $lecturersSorted)
+            ];
+        }
+
+        $tmpname = md5(uniqid('ErgebnisVeranstaltungssuche'));
+        if (array_to_csv($data, $GLOBALS['TMP_PATH'] . '/' . $tmpname, $captions)) {
+            $this->render_text(FileManager::getDownloadURLForTemporaryFile(
+                $tmpname,
+                'veranstaltungssuche.csv'
+            ));
+        } else {
+            $this->set_status(400, 'The csv could not be created.');
+        }
+    }
+}
diff --git a/app/views/admin/courses/batch_assign_semtree.php b/app/views/admin/courses/batch_assign_semtree.php
new file mode 100644
index 00000000000..b28e96bb611
--- /dev/null
+++ b/app/views/admin/courses/batch_assign_semtree.php
@@ -0,0 +1,8 @@
+<?php
+/**
+ * @var Course $course
+ */
+?>
+<label>
+    <input name="assign_semtree[]" type="checkbox" value="<?= htmlReady($course->id) ?>">
+</label>
diff --git a/app/views/admin/courses/courses.php b/app/views/admin/courses/courses.php
index 0d19b001782..830ac4da623 100644
--- a/app/views/admin/courses/courses.php
+++ b/app/views/admin/courses/courses.php
@@ -14,7 +14,8 @@ $colspan = 2
 ?>
 
 <? if (!empty($actions[$selected_action]['multimode'])) : ?>
-    <form action="<?= URLHelper::getLink($actions[$selected_action]['url']) ?>" method="post">
+    <form action="<?= URLHelper::getLink($actions[$selected_action]['url']) ?>" method="post"
+          <?= !empty($actions[$selected_action]['dialogform']) ? ' data-dialog="auto"' : '' ?>>
 <? endif ?>
 <?= CSRFProtection::tokenTag() ?>
 <table class="default course-admin">
diff --git a/app/views/admin/tree/assign_courses.php b/app/views/admin/tree/assign_courses.php
new file mode 100644
index 00000000000..df57aef0aca
--- /dev/null
+++ b/app/views/admin/tree/assign_courses.php
@@ -0,0 +1,10 @@
+<form action="<?= $controller->link_for('admin/tree/do_batch_assign') ?>" method="post">
+    <section>
+        <?= $search->render() ?>
+    </section>
+    <input type="hidden" name="node" value="<?= htmlReady($node) ?>">
+    <footer data-dialog-button>
+        <?= Studip\Button::createAccept(_('Zuordnen'), 'assign') ?>
+        <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+    </footer>
+</form>
diff --git a/app/views/admin/tree/batch_assign_semtree.php b/app/views/admin/tree/batch_assign_semtree.php
new file mode 100644
index 00000000000..c2866028118
--- /dev/null
+++ b/app/views/admin/tree/batch_assign_semtree.php
@@ -0,0 +1,43 @@
+<form class="default" action="<?= $controller->link_for('admin/tree/do_batch_assign') ?>" method="post">
+    <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>
+    </fieldset>
+    <fieldset>
+        <legend><?= _('Diese Veranstaltungen werden zugewiesen') ?></legend>
+        <table class="default selected-courses">
+            <colgroup>
+                <col>
+            </colgroup>
+            <thead>
+                <tr>
+                    <th><?= _('Name') ?></th>
+                </tr>
+            </thead>
+            <tbody>
+                <? foreach ($courses as $course) : ?>
+                    <tr>
+                        <td>
+                            <a href="<?= URLHelper::getLink('dispatch.php/course/overview', ['cid' => $course->id])?>"
+                               title="<?= sprintf(_('Zur Veranstaltung %s'), htmlReady($course->getFullname())) ?>"
+                               target="_blank">
+                                <?= htmlReady($course->getFullname('number-name-semester')) ?>
+                            </a>
+                            <input type="hidden" name="courses[]" value="<?= htmlReady($course->id) ?>">
+                        </td>
+                    </tr>
+                <? endforeach ?>
+            </tbody>
+        </table>
+    </fieldset>
+    <? if ($return) : ?>
+        <input type="hidden" name="return" value="<?= htmlReady($return) ?>">
+    <? endif ?>
+    <footer data-dialog-button>
+        <?= Studip\Button::createAccept(_('Speichern'), 'store') ?>
+        <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+    </footer>
+</form>
diff --git a/app/views/admin/tree/create.php b/app/views/admin/tree/create.php
new file mode 100644
index 00000000000..6b7255fdc6a
--- /dev/null
+++ b/app/views/admin/tree/create.php
@@ -0,0 +1,49 @@
+<form class="default" action="<?= $controller->link_for('admin/tree/store', get_class($node), $node->id ?: null) ?>" method="post">
+    <section>
+        <label>
+            <?= _('Name') ?>
+            <input type="text" name="name"
+                   placeholder="<?= _('Name des Eintrags (wird bei Zuweisung zu einer Stud.IP-Einrichtung überschrieben)') ?>">
+        </label>
+    </section>
+    <? if (get_class($node) === StudipStudyArea::class): ?>
+        <section>
+            <label>
+                <?= _('Infotext') ?>
+                <textarea name="description" rows="3"></textarea>
+            </label>
+        </section>
+        <section>
+            <label>
+                <?= _('Typ') ?>
+                <select name="type">
+                    <? foreach ($GLOBALS['SEM_TREE_TYPES'] as $index => $type) : ?>
+                        <option value="<?= htmlReady($index) ?>">
+                            <?= $type['name'] ?: _('Standard') ?>
+                            <?= !$type['editable'] ? _('(nicht mehr nachträglich änderbar)') : '' ?>
+                            <?= $type['hidden'] ? _('(dieser Knoten ist versteckt)') : '' ?>
+                        </option>
+                    <? endforeach ?>
+                </select>
+            </label>
+        </section>
+    <? endif ?>
+    <section>
+        <label>
+            <?= _('Elternelement') ?>
+            <?= $treesearch->render() ?>
+        </label>
+    </section>
+    <section>
+        <label>
+            <?= _('Zu einer Stud.IP-Einrichtung zuordnen') ?>
+            <?= $instsearch->render() ?>
+        </label>
+    </section>
+    <input type="hidden" name="from" value="<?= htmlReady($from) ?>">
+    <?= CSRFProtection::tokenTag() ?>
+    <footer data-dialog-button>
+        <?= Studip\Button::createAccept(_('Speichern'), 'store') ?>
+        <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+    </footer>
+</form>
diff --git a/app/views/admin/tree/edit.php b/app/views/admin/tree/edit.php
new file mode 100644
index 00000000000..0076b95c8ff
--- /dev/null
+++ b/app/views/admin/tree/edit.php
@@ -0,0 +1,54 @@
+<form class="default" action="<?= $controller->link_for('admin/tree/store', get_class($node), $node->id) ?>" method="post">
+    <section>
+        <label>
+            <?= (get_class($node) === RangeTreeNode::class && $node->studip_object_id)
+                ? _('Name (kann hier nicht bearbeitet werden, da es sich um ein Stud.IP-Objekt handelt)')
+                : _('Name') ?>
+            <input type="text" name="name"
+                   value="<?= htmlReady($node->getName()) ?>"
+                   <?= get_class($node) === RangeTreeNode::class && $node->studip_object_id ? ' disabled' : '' ?>>
+        </label>
+    </section>
+    <? if (get_class($node) === StudipStudyArea::class): ?>
+        <section>
+            <label>
+                <?= _('Infotext') ?>
+                <textarea name="description" rows="3"><?= htmlReady($node->info) ?></textarea>
+            </label>
+        </section>
+        <section>
+            <label>
+                <?= _('Typ') ?>
+                <select name="type"<?= empty($GLOBALS['SEM_TREE_TYPES'][$node->type]['editable']) ? ' disabled' : '' ?>>
+                    <? foreach ($GLOBALS['SEM_TREE_TYPES'] as $index => $type) : ?>
+                        <option value="<?= htmlReady($index) ?>"<?= $node->type == $index ? ' selected' : '' ?>>
+                            <?= $type['name'] ?: _('Standard') ?>
+                            <?= !$type['editable'] ? _('(nicht mehr nachträglich änderbar)') : '' ?>
+                            <?= $type['hidden'] ? _('(dieser Knoten ist versteckt)') : '' ?>
+                        </option>
+                    <? endforeach ?>
+                </select>
+            </label>
+        </section>
+    <? endif ?>
+    <section>
+        <label>
+            <?= _('Elternelement') ?>
+            <?= $treesearch->render() ?>
+        </label>
+    </section>
+    <? if (get_class($node) === RangeTreeNode::class): ?>
+        <section>
+            <label>
+                <?= _('Zu einer Stud.IP-Einrichtung zuordnen') ?>
+                <?= $instsearch->render() ?>
+            </label>
+        </section>
+    <? endif ?>
+    <input type="hidden" name="from" value="<?= $from ?>">
+    <?= CSRFProtection::tokenTag() ?>
+    <footer data-dialog-button>
+        <?= Studip\Button::createAccept(_('Speichern'), 'store') ?>
+        <?= Studip\Button::createCancel(_('Abbrechen'), 'cancel', ['data-dialog' => 'close']) ?>
+    </footer>
+</form>
diff --git a/app/views/admin/tree/rangetree.php b/app/views/admin/tree/rangetree.php
new file mode 100644
index 00000000000..1e3e9453f53
--- /dev/null
+++ b/app/views/admin/tree/rangetree.php
@@ -0,0 +1,9 @@
+<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
new file mode 100644
index 00000000000..0c48245a6ed
--- /dev/null
+++ b/app/views/admin/tree/semtree.php
@@ -0,0 +1,10 @@
+<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/search/courses/index.php b/app/views/search/courses/index.php
index 63b9dc44aa2..c6f49050601 100644
--- a/app/views/search/courses/index.php
+++ b/app/views/search/courses/index.php
@@ -1,2 +1,12 @@
-<?
-$sem_browse_obj->do_output();
+<?php
+/**
+ * @var String $startId
+ * @var String $nodeClass
+ */
+?>
+<div data-studip-tree>
+    <studip-tree start-id="<?= htmlReady($startId) ?>" view-type="list" :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/app/views/studyarea/edit.php b/app/views/studyarea/edit.php
new file mode 100644
index 00000000000..45a48d133d2
--- /dev/null
+++ b/app/views/studyarea/edit.php
@@ -0,0 +1 @@
+<?= $form->render() ?>
diff --git a/db/migrations/5.4.6_tree_changes.php b/db/migrations/5.4.6_tree_changes.php
new file mode 100644
index 00000000000..94d015caed9
--- /dev/null
+++ b/db/migrations/5.4.6_tree_changes.php
@@ -0,0 +1,66 @@
+<?
+
+final class TreeChanges extends Migration
+{
+
+    const FIELDS = [
+        'RANGE_TREE_PERM',
+        'SEM_TREE_PERM'
+    ];
+
+    public function description()
+    {
+        return 'Removes old sem_- and range_tree permission settings and institute assignments for sem_tree entries';
+    }
+
+    protected function up()
+    {
+        // Remove config fields for special permissions concerning sem_- and range_tree administration.
+        DBManager::get()->execute(
+            "DELETE FROM `config_values` WHERE `field` IN (:fields)",
+            ['fields' => self::FIELDS]
+        );
+        DBManager::get()->execute(
+            "DELETE FROM `config` WHERE `field` IN (:fields)",
+            ['fields' => self::FIELDS]
+        );
+
+        // "Transfer" names from assigned institutes to sem_tree entries.
+        $stmt = DBManager::get()->prepare("UPDATE `sem_tree` SET `name` = :name WHERE `studip_object_id` = :inst");
+        $query = "SELECT DISTINCT `Institut_id`, `Name` FROM `Institute` WHERE `Institut_id` IN (
+                SELECT DISTINCT `studip_object_id` FROM  `sem_tree`
+            )";
+        foreach (DBManager::get()->fetchAll($query) as $institute) {
+            $stmt->execute(['name' => $institute['Name'], 'inst' => $institute['Institut_id']]);
+        }
+        // Remove institute assignments for sem_tree entries.
+        DBManager::get()->exec("ALTER TABLE `sem_tree` DROP `studip_object_id`");
+    }
+
+    protected function down()
+    {
+        // Restore config entries to their defaults.
+        DBManager::get()->exec("INSERT IGNORE INTO `config`
+                ( `config_id` , `parent_id` , `field` , `value` ,
+                 `is_default` , `type` , `range` , `section` ,
+                  `position` , `mkdate` , `chdate` , `description` ,
+                   `comment` , `message_template` )
+                VALUES (
+                MD5( 'RANGE_TREE_ADMIN_PERM' ) , '', 'RANGE_TREE_ADMIN_PERM',
+                 'admin', '1', 'string', 'global', '', '0',
+                  UNIX_TIMESTAMP( ) , UNIX_TIMESTAMP( ) ,
+                   'mit welchem Status darf die Einrichtungshierarchie bearbeitet werden (admin oder root)', '', ''
+                ), (
+                MD5( 'SEM_TREE_ADMIN_PERM' ) , '', 'SEM_TREE_ADMIN_PERM',
+                 'admin', '1', 'string', 'global', '', '0', UNIX_TIMESTAMP( ) ,
+                  UNIX_TIMESTAMP( ) , 'mit welchem Status darf die Veranstaltungshierarchie bearbeitet werden (admin oder root)', '', ''
+                )");
+
+        // Add database column for sem_tree institute assignments.
+        DBManager::get()->exec("ALTER TABLE `sem_tree` ADD
+            `studip_object_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NULL DEFAULT NULL AFTER `name`");
+        // Add index for studip_object_id.
+        DBManager::get()->exec("ALTER TABLE `sem_tree` ADD INDEX `studip_object_id` (`studip_object_id`)");
+    }
+
+}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index f60c4dd5d15..52d4402e54b 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -132,6 +132,7 @@ class RouteMap
         $this->addAuthenticatedMessagesRoutes($group);
         $this->addAuthenticatedNewsRoutes($group);
         $this->addAuthenticatedStudyAreasRoutes($group);
+        $this->addAuthenticatedTreeRoutes($group);
         $this->addAuthenticatedWikiRoutes($group);
     }
 
@@ -281,6 +282,17 @@ class RouteMap
         $group->get('/study-areas/{id}/parent', Routes\StudyAreas\ParentOfStudyAreas::class);
     }
 
+    private function addAuthenticatedTreeRoutes(RouteCollectorProxy $group): void
+    {
+        $group->get('/tree-node/{id}', Routes\Tree\TreeShow::class);
+
+        $group->get('/tree-node/{id}/children', Routes\Tree\ChildrenOfTreeNode::class);
+        $group->get('/tree-node/{id}/courseinfo', Routes\Tree\CourseInfoOfTreeNode::class);
+        $group->get('/tree-node/{id}/courses', Routes\Tree\CoursesOfTreeNode::class);
+        $group->get('/tree-node/course/pathinfo/{classname}/{id}', Routes\Tree\PathinfoOfTreeNodeCourse::class);
+        $group->get('/tree-node/course/details/{id}', Routes\Tree\DetailsOfTreeNodeCourse::class);
+    }
+
     private function addAuthenticatedWikiRoutes(RouteCollectorProxy $group): void
     {
         $this->addRelationship($group, '/wiki-pages/{id:.+}/relationships/parent', Routes\Wiki\Rel\ParentPage::class);
diff --git a/lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php
new file mode 100644
index 00000000000..8773c0a5b60
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/ChildrenOfRangeTreeNode.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class ChildrenOfRangeTreeNode extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        'children',
+        'courses',
+        'institute',
+        'parent',
+    ];
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!RangeTreeNode::getNode($args['id'])) {
+            throw new RecordNotFoundException();
+        }
+
+        list($offset, $limit) = $this->getOffsetAndLimit();
+        $total = \RangeTreeNode::countByParent_id($args['id']);
+        $children = \RangeTreeNode::findByParent_id(
+            $args['id'],
+            "LIMIT {$offset}, {$limit}"
+        );
+
+        return $this->getPaginatedContentResponse($children, $total);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php
new file mode 100644
index 00000000000..980cac104af
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/CoursesOfRangeTreeNode.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class CoursesOfRangeTreeNode extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        'blubber-threads',
+        'end-semester',
+        'events',
+        'feedback-elements',
+        'file-refs',
+        'folders',
+        'forum-categories',
+        'institute',
+        'memberships',
+        'news',
+        'participating-institutes',
+        'sem-class',
+        'sem-type',
+        'start-semester',
+        'status-groups',
+        'wiki-pages',
+    ];
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $node = \RangeTreeNode::find($args['id']);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        list($offset, $limit) = $this->getOffsetAndLimit();
+        $courses = $node->getCourses();
+
+        return $this->getPaginatedContentResponse(
+            $courses->limit($offset, $limit),
+            count($courses)
+        );
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php
new file mode 100644
index 00000000000..6ecf52a6033
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/InstituteOfRangeTreeNode.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+use JsonApi\Schemas\Institute as InstituteSchema;
+
+class InstituteOfRangeTreeNode extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        InstituteSchema::REL_STATUS_GROUPS,
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $node = \RangeTreeNode::find($args['id']);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($node->institute);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php b/lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php
new file mode 100644
index 00000000000..01a40d3cd36
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/ParentOfRangeTreeNode.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class ParentOfRangeTreeNode extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        'children',
+        'courses',
+        'institute',
+        'parent',
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $node = \RangeTreeNode::find($args['id']);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($node->getParent());
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php
new file mode 100644
index 00000000000..c706c482204
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeIndex.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Zeigt eine bestimmte Veranstaltung an.
+ */
+class RangeTreeIndex extends JsonApiController
+{
+
+    protected $allowedIncludePaths = [
+        'children',
+        'courses',
+        'institute',
+        'parent',
+    ];
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $tree = \TreeAbstract::getInstance('StudipSemTree', ['visible_only' => 1]);
+        $studyAreas = self::mapTree('root', $tree);
+        list($offset, $limit) = $this->getOffsetAndLimit();
+
+        return $this->getPaginatedContentResponse(
+            array_slice($studyAreas, $offset, $limit),
+            count($studyAreas)
+        );
+    }
+
+    private function mapTree($parentId, &$tree)
+    {
+        $level = [];
+        $kids = $tree->getKids($parentId);
+        if (is_array($kids) && count($kids) > 0) {
+            foreach ($kids as $kid) {
+                $level[] = \StudipStudyArea::find($kid);
+                $level = array_merge($level, self::mapTree($kid, $tree));
+            }
+        }
+
+        return $level;
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php
new file mode 100644
index 00000000000..c9f52ed1178
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/RangeTree/RangeTreeShow.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\RangeTree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class RangeTreeShow extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        'children',
+        'courses',
+        'institute',
+        'parent',
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $node = \RangeTreeNode::find($args['id']);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($node);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php b/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php
index 628abc828bd..9ce57289658 100644
--- a/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php
+++ b/lib/classes/JsonApi/Routes/StudyAreas/StudyAreasShow.php
@@ -22,7 +22,8 @@ class StudyAreasShow extends JsonApiController
      */
     public function __invoke(Request $request, Response $response, $args)
     {
-        if (!$studyArea = \StudipStudyArea::find($args['id'])) {
+        $studyArea = \StudipStudyArea::find($args['id']);
+        if (!$studyArea && $args['id'] !== 'root') {
             throw new RecordNotFoundException();
         }
 
diff --git a/lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php b/lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php
new file mode 100644
index 00000000000..6419d03c6ce
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/ChildrenOfTreeNode.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class ChildrenOfTreeNode extends JsonApiController
+{
+    protected $allowedFilteringParameters = ['visible'];
+
+    protected $allowedIncludePaths = [
+        'children',
+        'courses',
+        'institute',
+        'parent',
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        list($classname, $id) = explode('_', $args['id']);
+
+        $node = $classname::getNode($id);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        $filters = $this->getContextFilters();
+
+        $data = $node->getChildNodes((bool) $filters['visible']);
+
+        return $this->getContentResponse($data);
+    }
+
+    private function getContextFilters()
+    {
+        $defaults = [
+            'visible' => false
+        ];
+
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        return array_merge($defaults, $filtering);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php b/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php
new file mode 100644
index 00000000000..283593150cf
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/CourseInfoOfTreeNode.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use JsonApi\Errors\BadRequestException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\NonJsonApiController;
+
+class CourseinfoOfTreeNode extends NonJsonApiController
+{
+    protected $allowedFilteringParameters = ['q', 'semester', 'semclass', 'recursive'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        list($classname, $id) = explode('_', $args['id']);
+
+        $node = $classname::getNode($id);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        $error = $this->validateFilters($request);
+        if ($error) {
+            throw new BadRequestException($error);
+        }
+
+        $filters = $this->getContextFilters($request);
+
+        $info = [
+            'courses' => (int) $node->countCourses($filters['semester'], $filters['semclass']),
+            'allCourses' => (int) $node->countCourses($filters['semester'], $filters['semclass'], true)
+        ];
+
+        $response->getBody()->write(json_encode($info));
+
+        return $response->withHeader('Content-type', 'application/json');
+    }
+
+    private function validateFilters($request)
+    {
+        $filtering = $request->getQueryParams()['filter'] ?: [];
+
+        // keyword aka q
+        if (isset($filtering['q']) && mb_strlen($filtering['q']) < 3) {
+            return 'Search term too short.';
+        }
+
+        // semester
+        if (isset($filtering['semester']) && $filtering['semester'] !== 'all') {
+            $semester = \Semester::find($filtering['semester']);
+            if (!$semester) {
+                return 'Invalid "semester".';
+            }
+        }
+
+        // course category
+        if (!empty($filtering['semclass'])) {
+            $semclass = \SeminarCategories::Get($filtering['semclass']);
+            if (!$semclass) {
+                return 'Invalid "course category".';
+            }
+        }
+    }
+
+    private function getContextFilters($request)
+    {
+        $defaults = [
+            'q' => '',
+            'semester' => 'all',
+            'semclass' => 0,
+            'recursive' => false
+        ];
+
+        $filtering = $request->getQueryParams()['filter'] ?: [];
+
+        return array_merge($defaults, $filtering);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php b/lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php
new file mode 100644
index 00000000000..623e6198520
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/CoursesOfTreeNode.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use JsonApi\Errors\BadRequestException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class CoursesOfTreeNode extends JsonApiController
+{
+    protected $allowedFilteringParameters = ['q', 'semester', 'semclass', 'recursive', 'ids'];
+
+    protected $allowedIncludePaths = [
+        'blubber-threads',
+        'end-semester',
+        'events',
+        'feedback-elements',
+        'file-refs',
+        'folders',
+        'forum-categories',
+        'institute',
+        'memberships',
+        'news',
+        'participating-institutes',
+        'sem-class',
+        'sem-type',
+        'start-semester',
+        'status-groups',
+        'wiki-pages',
+    ];
+    protected $allowedPagingParameters = ['offset', 'limit'];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        list($classname, $id) = explode('_', $args['id']);
+
+        $node = $classname::getNode($id);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        $error = $this->validateFilters();
+        if ($error) {
+            throw new BadRequestException($error);
+        }
+
+        $filters = $this->getContextFilters();
+
+        list($offset, $limit) = $this->getOffsetAndLimit();
+        $courses = \SimpleCollection::createFromArray(
+            $node->getCourses(
+                $filters['semester'],
+                $filters['semclass'],
+                $filters['q'],
+                (bool) $filters['recursive'],
+                $filters['ids']
+            )
+        );
+
+        return $this->getPaginatedContentResponse(
+            $courses->limit($offset, $limit),
+            count($courses)
+        );
+    }
+
+    private function validateFilters()
+    {
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        // keyword aka q
+        if (isset($filtering['q']) && mb_strlen($filtering['q']) < 3) {
+            return 'Search term too short.';
+        }
+
+        // semester
+        if (isset($filtering['semester']) && $filtering['semester'] !== 'all') {
+            $semester = \Semester::find($filtering['semester']);
+            if (!$semester) {
+                return 'Invalid "semester".';
+            }
+        }
+
+        // course category
+        if (!empty($filtering['semclass'])) {
+            $semclass = \SeminarCategories::Get($filtering['semclass']);
+            if (!$semclass) {
+                return 'Invalid "course category".';
+            }
+        }
+    }
+
+    private function getContextFilters()
+    {
+        $defaults = [
+            'q' => '',
+            'semester' => 'all',
+            'semclass' => 0,
+            'recursive' => false,
+            'ids' => []
+        ];
+
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        return array_merge($defaults, $filtering);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php b/lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php
new file mode 100644
index 00000000000..cef1077ecb7
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/DetailsOfTreeNodeCourse.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\NonJsonApiController;
+
+class DetailsOfTreeNodeCourse extends NonJsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $course = \Course::find($args['id']);
+        if (!$course) {
+            throw new RecordNotFoundException();
+        }
+
+        // Get course dates in textual form
+        $dates = \Seminar::GetInstance($args['id'])->getDatesHTML([
+            'semester_id' => null,
+            'show_room'   => true,
+        ]);
+
+        $data = [
+            'semester' => $course->semester_text,
+            'lecturers' => [],
+            'admissionstate' => null,
+            'dates' => $dates
+        ];
+
+        // Get lecturers
+        $lecturers = \SimpleCollection::createFromArray(
+            \CourseMember::findByCourseAndStatus($args['id'], 'dozent')
+        )->orderBy('position, nachname, vorname');
+        foreach ($lecturers as $l) {
+            $data['lecturers'][] = [
+                'id' => $l->user_id,
+                'username' => $l->username,
+                'name' => $l->getUserFullname()
+            ];
+        }
+
+        // Get admission state indicator if necessary
+        if (\Config::get()->COURSE_SEARCH_SHOW_ADMISSION_STATE) {
+            switch (\GlobalSearchCourses::getStatusCourseAdmission($course->id, $course->admission_prelim)) {
+                case 1:
+                    $data['admissionstate'] = [
+                        'icon' => 'decline-circle',
+                        'role' => \Icon::ROLE_STATUS_YELLOW,
+                        'info' => _('Eingeschränkter Zugang')
+                    ];
+                    break;
+                case 2:
+                    $data['admissionstate'] = [
+                        'icon' => 'decline-circle',
+                        'role' => \Icon::ROLE_STATUS_RED,
+                        'info' => _('Kein Zugang')
+                    ];
+                    break;
+                default:
+                    $data['admissionstate'] = [
+                        'icon' => 'check-circle',
+                        'role' => \Icon::ROLE_STATUS_GREEN,
+                        'info' => _('Uneingeschränkter Zugang')
+                    ];
+            }
+
+        }
+
+        $response->getBody()->write(json_encode($data));
+
+        return $response->withHeader('Content-type', 'application/json');
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php b/lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php
new file mode 100644
index 00000000000..282b7f94890
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/PathinfoOfTreeNodeCourse.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\NonJsonApiController;
+
+class PathinfoOfTreeNodeCourse extends NonJsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        $course = \Course::find($args['id']);
+        if (!$course) {
+            throw new RecordNotFoundException();
+        }
+
+        $classname = $args['classname'];
+
+        $path = [];
+        foreach ($classname::getCourseNodes($args['id']) as $node) {
+            $path[] = $node->getAncestors();
+        }
+
+        $response->getBody()->write(json_encode($path));
+
+        return $response->withHeader('Content-type', 'application/json');
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/Tree/TreeShow.php b/lib/classes/JsonApi/Routes/Tree/TreeShow.php
new file mode 100644
index 00000000000..1eaa7972c6e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Tree/TreeShow.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace JsonApi\Routes\Tree;
+
+use JsonApi\Errors\BadRequestException;
+use Neomerx\JsonApi\Contracts\Http\ResponsesInterface;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+class TreeShow extends JsonApiController
+{
+    protected $allowedIncludePaths = [
+        'children',
+        'courseinfo',
+        'courses',
+        'institute',
+        'parent'
+    ];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameters)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        list($classname, $id) = explode('_', $args['id']);
+
+        $node = $classname::getNode($id);
+        if (!$node) {
+            throw new RecordNotFoundException();
+        }
+
+        return $this->getContentResponse($node);
+    }
+
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 19d21f5648d..f4ac20796ed 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -43,7 +43,7 @@ class SchemaMap
             \JsonApi\Models\StudipProperty::class => Schemas\StudipProperty::class,
             \StudipComment::class => Schemas\StudipComment::class,
             \StudipNews::class => Schemas\StudipNews::class,
-            \StudipStudyArea::class => Schemas\StudyArea::class,
+            \StudipTreeNode::class => Schemas\TreeNode::class,
             \WikiPage::class => Schemas\WikiPage::class,
             \Studip\Activity\Activity::class => Schemas\Activity::class,
             \User::class => Schemas\User::class,
diff --git a/lib/classes/JsonApi/Schemas/StudyArea.php b/lib/classes/JsonApi/Schemas/StudyArea.php
index f077d83b70b..e9779c70109 100644
--- a/lib/classes/JsonApi/Schemas/StudyArea.php
+++ b/lib/classes/JsonApi/Schemas/StudyArea.php
@@ -1,5 +1,4 @@
 <?php
-
 namespace JsonApi\Schemas;
 
 use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
@@ -25,6 +24,9 @@ class StudyArea extends SchemaProvider
             'info' => (string) $resource['info'],
             'priority' => (int) $resource['priority'],
             'type-name' => (string) $resource->getTypeName(),
+            'has-children' => (bool) $resource->hasChildNodes(),
+            'ancestors' => (array) $resource->getAncestors(),
+            'classname' => get_class($resource)
         ];
     }
 
diff --git a/lib/classes/JsonApi/Schemas/TreeNode.php b/lib/classes/JsonApi/Schemas/TreeNode.php
new file mode 100644
index 00000000000..90e6846de9d
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/TreeNode.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Factories\FactoryInterface;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Contracts\Schema\SchemaContainerInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class TreeNode extends SchemaProvider
+{
+    const REL_CHILDREN = 'children';
+
+    const REL_COURSEINFO = 'courseinfo';
+    const REL_COURSES = 'courses';
+    const REL_INSTITUTE = 'institute';
+    const REL_PARENT = 'parent';
+
+    const TYPE = 'tree-node';
+
+    public function getId($resource): ?string
+    {
+        return get_class($resource) . '_' . $resource['id'];
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        $schema = [
+            'id' => (string) $resource->getId(),
+            'name' => (string) $resource->getName(),
+            'description' => (string) $resource->getDescription(),
+            'description-formatted' => (string) formatReady($resource->getDescription()),
+            'has-children' => (bool) $resource->hasChildNodes(),
+            'ancestors' => (array) $resource->getAncestors(),
+            'classname' => get_class($resource),
+            'visible' => true,
+            'editable' => true,
+            'assignable' => true
+        ];
+
+        // Some special options for sem_tree entries.
+        if (get_class($resource) === 'StudipStudyArea') {
+            if ($GLOBALS['SEM_TREE_TYPES'][$resource->type]['hidden'] ?? false) {
+                $schema['visible'] = false;
+            }
+            if ($GLOBALS['SEM_TREE_TYPES'][$resource->type]['editable'] ?? false) {
+                $schema['editable'] = false;
+            }
+            if (!\Config::get()->SEM_TREE_ALLOW_BRANCH_ASSIGN && $resource->hasChildNodes()) {
+                $schema['assignable'] = false;
+            }
+        }
+
+        return $schema;
+    }
+
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships = $this->addChildrenRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_CHILDREN));
+
+        if (property_exists($resource, 'courses') || method_exists($resource, 'getCourses')) {
+            $relationships = $this->addCourseInfoRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_COURSEINFO));
+            $relationships = $this->addCoursesRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_COURSES));
+        }
+        $relationships = $this->addInstituteRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_INSTITUTE));
+        $relationships = $this->addParentRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_PARENT));
+
+        return $relationships;
+    }
+
+    private function addChildrenRelationship(array $relationships, $resource, $includeData)
+    {
+        $relationships[self::REL_CHILDREN] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_CHILDREN),
+            ]
+        ];
+
+        if ($includeData) {
+            $children = $resource->getChildNodes();
+            $relationships[self::REL_CHILDREN][self::RELATIONSHIP_DATA] = $children;
+        }
+
+        return $relationships;
+    }
+
+
+    private function addCourseInfoRelationship(array $relationships, $resource, $includeData)
+    {
+        $relationships[self::REL_COURSEINFO] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_COURSEINFO),
+            ],
+        ];
+
+        if ($includeData) {
+            $children = $resource->courses;
+            $relationships[self::REL_COURSES][self::RELATIONSHIP_DATA] = $children;
+        }
+
+        return $relationships;
+    }
+
+    private function addCoursesRelationship(array $relationships, $resource, $includeData)
+    {
+        $relationships[self::REL_COURSES] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_COURSES)
+            ]
+        ];
+
+        if ($includeData) {
+            $courses = $resource->courses;
+            $relationships[self::REL_COURSES][self::RELATIONSHIP_DATA] = $courses;
+        }
+
+        return $relationships;
+    }
+
+    private function addInstituteRelationship(array $relationships, $resource, $includeData)
+    {
+        $relationships[self::REL_INSTITUTE] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_INSTITUTE),
+            ],
+        ];
+
+        if ($includeData) {
+            $relationships[self::REL_INSTITUTE][self::RELATIONSHIP_DATA] = $resource->institute;
+        }
+
+        return $relationships;
+    }
+
+    private function addParentRelationship(array $relationships, $resource, $includeData)
+    {
+        $relationships[self::REL_PARENT] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($resource, self::REL_PARENT)
+            ],
+        ];
+
+        if ($includeData) {
+            $relationships[self::REL_PARENT][self::RELATIONSHIP_DATA] = $resource->getParent();
+        }
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/SemBrowse.class.php b/lib/classes/SemBrowse.class.php
index 16f13f16b42..65d28565f9b 100644
--- a/lib/classes/SemBrowse.class.php
+++ b/lib/classes/SemBrowse.class.php
@@ -1185,19 +1185,13 @@ class SemBrowse {
                     return new Navigation(_('Vorlesungsverzeichnis'),
                             URLHelper::getURL('dispatch.php/search/courses',
                                     [
-                                        'level' => 'vv',
-                                        'cmd'   => 'qs',
-                                        'sset'  => '0',
-                                        'option' => ''
+                                        'type' => 'semtree'
                                     ], true));
                 case 'rangetree':
                     return new Navigation(_('Einrichtungsverzeichnis'),
                             URLHelper::getURL('dispatch.php/search/courses',
                                     [
-                                        'level' => 'ev',
-                                        'cmd'   => 'qs',
-                                        'sset'  => '0',
-                                        'option' => ''
+                                        'type' => 'rangetree'
                                     ], true));
                 case 'module':
                     return new MVVSearchNavigation(_('Modulverzeichnis'),
diff --git a/lib/classes/StudipTreeNode.php b/lib/classes/StudipTreeNode.php
new file mode 100644
index 00000000000..a1d72586d71
--- /dev/null
+++ b/lib/classes/StudipTreeNode.php
@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * Interface StudipTreeNode
+ * An abstract representation of a tree node in Stud.IP
+ *
+ * @author Thomas Hackl <hackl@data-quest.de>
+ * @license GPL2 or any later version
+ * @since   Stud.IP 5.3
+ */
+
+interface StudipTreeNode
+{
+
+    /**
+     * Fetches a node by the given ID. The implementing class knows what to do.
+     *
+     * @param mixed $id
+     * @return StudipTreeNode
+     */
+    public static function getNode($id): StudipTreeNode;
+
+    /**
+     * Get all direct children of the given node.
+     *
+     * @param bool $onlyVisible fetch only visible nodes?
+     * @return StudipTreeNode[]
+     */
+    public function getChildNodes(bool $onlyVisible = false): array;
+
+    /**
+     * Fetches an array of all nodes the given course is assigned to.
+     *
+     * @param string $course_id
+     * @return array
+     */
+    public static function getCourseNodes(string $course_id): array;
+
+    /**
+     * This node's unique ID.
+     *
+     * @return mixed
+     */
+    public function getId();
+
+    /**
+     * A name (=label) for this node.
+     *
+     * @return string
+     */
+    public function getName(): string;
+
+    /**
+     * Optional description for this node.
+     *
+     * @return string
+     */
+    public function getDescription(): string;
+
+    /**
+     * Gets an optional Image (Icon or Avatar) for this node.
+     *
+     * @return Icon|Avatar|null
+     */
+    public function getImage();
+
+    /**
+     * Indicator if this node has children.
+     *
+     * @return bool
+     */
+    public function hasChildNodes(): bool;
+
+    /**
+     * How many courses are assigned to this node in the given semester?
+     *
+     * @param string $semester_id
+     * @param int $semclass
+     * @param bool $with_children
+     * @return int
+     */
+    public function countCourses(
+        string $semester_id = '',
+        int $semclass = 0,
+        bool $with_children = false
+    ): int;
+
+    /**
+     * Fetches courses assigned to this node in the given semester.
+     *
+     * @param string $semester_id
+     * @param int $semclass
+     * @param string $searchterm
+     * @param bool $with_children
+     * @param string[] $courses
+     *
+     * @return Course[]
+     */
+    public function getCourses(
+        string $semester_id = 'all',
+        int $semclass = 0,
+        string $searchterm = '',
+        bool $with_children = false,
+        array $courses = []
+    ): array;
+
+    /**
+     * Returns an array containing all ancestor nodes with id and name.
+     *
+     * @return array
+     */
+    public function getAncestors(): array;
+
+}
diff --git a/lib/classes/forms/QuicksearchInput.php b/lib/classes/forms/QuicksearchInput.php
index 2531a2e19c3..f4be5473934 100644
--- a/lib/classes/forms/QuicksearchInput.php
+++ b/lib/classes/forms/QuicksearchInput.php
@@ -6,7 +6,7 @@ class QuicksearchInput extends Input
 {
     public function render()
     {
-        $template = $GLOBALS['template_factory']->open('forms/checkbox_input');
+        $template = $GLOBALS['template_factory']->open('forms/quicksearch_input');
         $template->title = $this->title;
         $template->name = $this->name;
         $template->value = $this->value;
diff --git a/lib/classes/searchtypes/TreeSearch.class.php b/lib/classes/searchtypes/TreeSearch.class.php
new file mode 100644
index 00000000000..2fecf60e0cd
--- /dev/null
+++ b/lib/classes/searchtypes/TreeSearch.class.php
@@ -0,0 +1,96 @@
+<?php
+/**
+ * TreeSearch.class.php - Class of type SearchType used for searches with QuickSearch
+ *
+ * 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.
+ *
+ * @author      Thomas Hackl <hackl@data-quest.de>
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ */
+
+class TreeSearch extends StandardSearch
+{
+    /**
+     *
+     * @param string $search The search type.
+     *
+     * @param Array $search_settings Settings for the selected seach type.
+     *     Depending on the search type different settings are possible
+     *     which can change the output or the display of the output
+     *     of the search. The array must be an associative array
+     *     with the setting as array key.
+     *     The following settings are implemented:
+     *     Search type 'room':
+     *     - display_seats: If set to true, the seats will be displayed
+     *       after the name of the room.
+     *
+     * @return void
+     */
+    public function __construct($search, $search_settings = [])
+    {
+        if (is_array($search_settings)) {
+            $this->search_settings = $search_settings;
+        }
+
+        $this->avatarLike = $this->search = $search;
+        $this->sql = $this->getSQL();
+    }
+
+    /**
+     * returns the title/description of the searchfield
+     *
+     * @return string title/description
+     */
+    public function getTitle()
+    {
+        switch ($this->search) {
+            case 'sem_tree_id':
+                return _('Studienbereich suchen');
+            case 'range_tree_id':
+                return _('Eintrag in der Einrichtungshierarchie suchen');
+            default:
+                throw new UnexpectedValueException('Invalid search type {$this->search}');
+        }
+    }
+
+    /**
+     * returns a sql-string appropriate for the searchtype of the current class
+     *
+     * @return string
+     */
+    private function getSQL()
+    {
+        switch ($this->search) {
+            case 'sem_tree_id':
+                return "SELECT `sem_tree_id`, `name`
+                        FROM `sem_tree`
+                        WHERE `name` LIKE :input
+                           OR `info` LIKE :input
+                       ORDER BY `name`";
+            case 'range_tree_id':
+                return "SELECT t.`item_id`, IF(t.`studip_object_id` IS NULL, t.`name`, i.`name`)
+                        FROM `range_tree` t
+                        LEFT JOIN `Institute` i ON (i.`Institut_id` = t.`studip_object_id`)
+                        WHERE t.`name` LIKE :input
+                           OR i.`Name` LIKE :input
+                        ORDER BY t.`name`, i.`Name`";
+            default:
+                throw new UnexpectedValueException("Invalid search type {$this->search}");
+        }
+    }
+
+    /**
+     * A very simple overwrite of the same method from SearchType class.
+     * returns the absolute path to this class for autoincluding this class.
+     *
+     * @return: path to this class
+     */
+    public function includePath()
+    {
+        return studip_relative_path(__FILE__);
+    }
+}
diff --git a/lib/models/RangeTreeNode.php b/lib/models/RangeTreeNode.php
new file mode 100644
index 00000000000..d1c18238396
--- /dev/null
+++ b/lib/models/RangeTreeNode.php
@@ -0,0 +1,263 @@
+<?php
+
+/**
+ * RangeTreeNode.php
+ * model class for table range_tree
+ *
+ * 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.
+ *
+ * @author      Thomas Hackl <hackl@data-quest.de>
+ * @copyright   2022 Stud.IP Core-Group
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ * @since       5.3
+ *
+ *
+ * @property string id database column
+ * @property string item_id database column
+ * @property string parent_id database column
+ * @property int level database column
+ * @property int priority database column
+ * @property string name database column
+ * @property string studip_object database column
+ * @property string studip_object_id database column
+ */
+class RangeTreeNode extends SimpleORMap implements StudipTreeNode
+{
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'range_tree';
+
+        $config['belongs_to']['institute'] = [
+            'class_name'  => Institute::class,
+            'foreign_key' => 'studip_object_id',
+        ];
+        $config['belongs_to']['parent'] = [
+            'class_name'  => RangeTreeNode::class,
+            'foreign_key' => 'parent_id',
+        ];
+        $config['has_many']['children'] = [
+            'class_name'  => RangeTreeNode::class,
+            'foreign_key' => 'item_id',
+            'assoc_foreign_key' => 'parent_id',
+            'order_by' => 'ORDER BY priority, name',
+            'on_delete' => 'delete'
+        ];
+
+        parent::configure($config);
+    }
+
+    public static function getNode($id): StudipTreeNode
+    {
+        if ($id === 'root') {
+            return static::build([
+                'id'   => 'root',
+                'name' => Config::get()->UNI_NAME_CLEAN,
+            ]);
+        }
+
+        return static::find($id);
+    }
+
+    public static function getCourseNodes(string $course_id): array
+    {
+        $nodes = [];
+        foreach (Course::find($course_id)->institutes as $institute) {
+            $range = self::findOneByStudip_object_id($institute->id);
+            if ($range) {
+                $nodes[] = $range;
+            }
+        }
+        return $nodes;
+    }
+
+    public function getName(): string
+    {
+        if ($this->id === 'root') {
+            return Config::get()->UNI_NAME_CLEAN;
+        }
+
+        if ($this->institute) {
+           return (string) $this->institute->name;
+        }
+
+        return $this->content['name'];
+    }
+
+    public function getDescription(): string
+    {
+        return '';
+    }
+
+    public function getImage()
+    {
+        return $this->institute ?
+            Avatar::getAvatar($this->studip_object_id) :
+            Icon::create('institute');
+    }
+
+    public function hasChildNodes(): bool
+    {
+        return count($this->children) > 0;
+    }
+
+    /**
+     * @see StudipTreeNode::getChildNodes()
+     */
+    public function getChildNodes(bool $onlyVisible = false): array
+    {
+        return self::findByParent_id($this->id, "ORDER BY `priority`, `name`");
+    }
+
+    /**
+     * @see StudipTreeNode::countCourses()
+     */
+    public function countCourses($semester_id = '', $semclass = 0, $with_children = false): int
+    {
+        if ($semester_id) {
+            $query = "SELECT COUNT(DISTINCT i.`seminar_id`)
+                      FROM `seminar_inst` i
+                      JOIN `seminare` s ON (s.`Seminar_id` = i.`seminar_id`)
+                      LEFT JOIN `semester_courses` sc ON (i.`seminar_id` = sc.`course_id`)
+                      WHERE i.`institut_id` IN (
+                          SELECT DISTINCT `studip_object_id`
+                          FROM `range_tree`
+                          WHERE `item_id` IN (:ids)
+                      ) AND (
+                          sc.`semester_id` = :semester
+                          OR sc.`semester_id` IS NULL
+                      )";
+            $parameters = [
+                'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+                'semester' => $semester_id
+            ];
+        } else {
+            $query = "SELECT COUNT(DISTINCT `seminar_id`)
+                      FROM `seminar_inst` i
+                      JOIN `seminare` s ON (s.`Seminar_id` = i.`seminar_id`)
+                      WHERE `institut_id` IN (
+                          SELECT DISTINCT `studip_object_id`
+                          FROM `range_tree`
+                          WHERE `item_id` IN (:ids)
+                      )";
+            $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]];
+        }
+
+        if ($semclass !== 0) {
+            $query .= "  AND s.`status` IN (:types)";
+            $parameters['types'] = array_map(
+                function ($type) {
+                    return $type['id'];
+                },
+                array_filter(
+                    SemType::getTypes(),
+                    function ($t) use ($semclass) { return $t['class'] === $semclass; }
+                )
+            );
+        }
+
+        return !$this->institute && !$with_children ? 0 : DBManager::get()->fetchColumn($query, $parameters);
+    }
+
+    public function getCourses(
+        $semester_id = 'all',
+        $semclass = 0,
+        $searchterm = '',
+        $with_children = false,
+        array $courses = []
+    ): array
+    {
+        if ($semester_id !== 'all') {
+            $query = "SELECT DISTINCT s.*
+                      FROM `seminare` s
+                      JOIN `seminar_inst` i ON (i.`seminar_id` = s.`Seminar_id`)
+                      LEFT JOIN `semester_courses` sem ON (sem.`course_id` = s.`Seminar_id`)
+                      WHERE i.`institut_id` IN (
+                          SELECT DISTINCT `studip_object_id`
+                          FROM `range_tree`
+                          WHERE `item_id` IN (:ids)
+                      ) AND (
+                          sem.`semester_id` = :semester
+                          OR sem.`semester_id` IS NULL
+                      )";
+
+            $parameters = [
+                'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+                'semester' => $semester_id
+            ];
+        } else {
+            $query = "SELECT DISTINCT s.*
+                      FROM `seminare` s
+                      JOIN `seminar_inst` i ON (i.`seminar_id` = s.`Seminar_id`)
+                      WHERE i.`institut_id` IN (
+                          SELECT DISTINCT `studip_object_id`
+                          FROM `range_tree`
+                          WHERE `item_id` IN (:ids)
+                      )";
+            $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]];
+        }
+
+        if ($searchterm) {
+            $query .= " AND s.`Name` LIKE :searchterm";
+            $parameters['searchterm'] = '%' . trim($searchterm) . '%';
+        }
+
+        if ($courses) {
+            $query .= " AND s.`Seminar_id` IN (:courses)";
+            $parameters['courses'] = $courses;
+        }
+
+        if ($semclass !== 0) {
+            $query .= "  AND s.`status` IN (:types)";
+            $parameters['types'] = array_map(
+                function ($type) {
+                    return $type['id'];
+                },
+                array_filter(
+                    SemType::getTypes(),
+                    function ($t) use ($semclass) { return $t['class'] === $semclass; }
+                )
+            );
+        }
+
+        if (Config::get()->IMPORTANT_SEMNUMBER) {
+            $query .= " ORDER BY s.`start_time`, s.`VeranstaltungsNummer`, s.`Name`";
+        } else {
+            $query .= " ORDER BY s.`start_time`, s.`Name`";
+        }
+
+        return DBManager::get()->fetchAll($query, $parameters, 'Course::buildExisting');
+    }
+
+    public function getDescendantIds()
+    {
+        $ids = [];
+
+        foreach ($this->children as $child) {
+            $ids = array_merge($ids, [$child->id], $child->getDescendantIds());
+        }
+
+        return $ids;
+    }
+
+    public function getAncestors(): array
+    {
+        $path = [
+            [
+                'id' => $this->id,
+                'name' => $this->getName(),
+                'classname' => self::class
+            ]
+        ];
+
+        if ($this->parent_id) {
+            $path = array_merge($this->getNode($this->parent_id)->getAncestors(), $path);
+        }
+
+        return $path;
+    }
+
+}
diff --git a/lib/models/StudipStudyArea.class.php b/lib/models/StudipStudyArea.class.php
index 66505261d24..49f008a6f87 100644
--- a/lib/models/StudipStudyArea.class.php
+++ b/lib/models/StudipStudyArea.class.php
@@ -29,7 +29,7 @@
  * @property SimpleORMapCollection courses has_and_belongs_to_many Course
  */
 
-class StudipStudyArea extends SimpleORMap
+class StudipStudyArea extends SimpleORMap implements StudipTreeNode
 {
     /**
      * This constant represents the key of the root area.
@@ -50,10 +50,6 @@ class StudipStudyArea extends SimpleORMap
             'class_name' => Course::class,
             'thru_table' => 'seminar_sem_tree',
         ];
-        $config['belongs_to']['institute'] = [
-            'class_name' => Institute::class,
-            'foreign_key' => 'studip_object_id',
-        ];
         $config['belongs_to']['_parent'] = [
             'class_name' => StudipStudyArea::class,
             'foreign_key' => 'parent_id',
@@ -124,11 +120,8 @@ class StudipStudyArea extends SimpleORMap
     /**
      * Get the display name of this study area.
      */
-    public function getName()
+    public function getName(): string
     {
-        if ($this->studip_object_id) {
-            return $this->institute ? $this->institute->name : _('Unbekannte Einrichtung');
-        }
         return $this->content['name'];
     }
 
@@ -283,26 +276,6 @@ class StudipStudyArea extends SimpleORMap
     }
 
 
-    /**
-     * Get the studip_object_id of this study area.
-     */
-    public function getStudipObjectId()
-    {
-        return $this->studip_object_id;
-    }
-
-
-    /**
-     * Set the studip_object_id of this study area.
-     */
-    public function setStudipObjectId($id)
-    {
-        $this->studip_object_id = (string) $id;
-        $this->resetRelation('institute');
-        return $this;
-    }
-
-
     /**
      * Returns the children of this study area.
      */
@@ -450,4 +423,191 @@ class StudipStudyArea extends SimpleORMap
         return $root;
     }
 
+    public static function getNode($id): StudipTreeNode
+    {
+        if ($id === 'root') {
+            return static::build([
+                'id'   => 'root',
+                'name' => Config::get()->UNI_NAME_CLEAN,
+            ]);
+        }
+
+        return static::find($id);
+    }
+
+    public static function getCourseNodes(string $course_id): array
+    {
+        return Course::find($course_id)->study_areas->getArrayCopy();
+    }
+
+    public function getDescription(): string
+    {
+        return $this->getInfo();
+    }
+
+    /**
+     * @see StudipTreeNode::getImage()
+     */
+    public function getImage()
+    {
+        return null;
+    }
+
+    public function hasChildNodes(): bool
+    {
+        return count($this->_children) > 0;
+    }
+
+    /**
+     * @see StudipTreeNode::getChildNodes()
+     */
+    public function getChildNodes(bool $onlyVisible = false): array
+    {
+        if ($onlyVisible) {
+            $visibleTypes = array_filter($GLOBALS['SEM_TREE_TYPES'], function ($t) {
+                return isset($t['hidden']) ? !$t['hidden'] : true;
+            });
+
+            return static::findBySQL(
+                "`parent_id` = :parent AND `type` IN (:types) ORDER BY `priority`, `name`",
+                ['parent' => $this->id, 'types' => $visibleTypes]
+            );
+        } else {
+            return static::findByParent_id($this->id, "ORDER BY `priority`, `name`");
+        }
+    }
+
+    /**
+     * @see StudipTreeNode::countCourses()
+     */
+    public function countCourses($semester_id = 'all', $semclass = 0, $with_children = false) :int
+    {
+        if ($semester_id !== 'all') {
+            $query = "SELECT COUNT(DISTINCT t.`seminar_id`)
+                      FROM `seminar_sem_tree` t
+                      JOIN `seminare` s ON (s.`Seminar_id` = t.`seminar_id`)
+                      LEFT JOIN `semester_courses` sc ON (t.`seminar_id` = sc.`course_id`)
+                      WHERE t.`sem_tree_id` IN (:ids)
+                        AND (
+                          sc.`semester_id` = :semester
+                          OR sc.`semester_id` IS NULL
+                        )";
+            $parameters = [
+                'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+                'semester' => $semester_id
+            ];
+        } else {
+            $query = "SELECT COUNT(DISTINCT t.`seminar_id`)
+                      FROM `seminar_sem_tree` t
+                      JOIN `seminare` s ON (s.`Seminar_id` = t.`seminar_id`)
+                      WHERE `sem_tree_id` IN (:ids)";
+            $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]];
+        }
+
+        if ($semclass !== 0) {
+            $query .= "  AND s.`status` IN (:types)";
+            $parameters['types'] = array_map(
+                function ($type) {
+                    return $type['id'];
+                },
+                array_filter(
+                    SemType::getTypes(),
+                    function ($t) use ($semclass) { return $t['class'] === $semclass; }
+                )
+            );
+        }
+
+        return $this->id === 'root' && !$with_children ? 0 : DBManager::get()->fetchColumn($query, $parameters);
+    }
+
+    public function getCourses(
+        $semester_id = 'all',
+        $semclass = 0,
+        $searchterm = '',
+        $with_children = false,
+        array $courses = []
+    ): array
+    {
+        if ($semester_id !== 'all') {
+            $query = "SELECT DISTINCT s.*
+                      FROM `seminare` s
+                      JOIN `seminar_sem_tree` t ON (t.`seminar_id` = s.`Seminar_id`)
+                      LEFT JOIN `semester_courses` sem ON (sem.`course_id` = s.`Seminar_id`)
+                      WHERE t.`sem_tree_id` IN (:ids)
+                        AND (
+                          sem.`semester_id` = :semester
+                          OR sem.`semester_id` IS NULL
+                        )";
+            $parameters = [
+                'ids' => $with_children ? $this->getDescendantIds() : [$this->id],
+                'semester' => $semester_id
+            ];
+        } else {
+            $query = "SELECT DISTINCT s.*
+                      FROM `seminare` s
+                      JOIN `seminar_sem_tree` t ON (t.`seminar_id` = s.`Seminar_id`)
+                      WHERE t.`sem_tree_id` IN (:ids)";
+            $parameters = ['ids' => $with_children ? $this->getDescendantIds() : [$this->id]];
+        }
+
+        if ($semclass !== 0) {
+            $query .= "  AND s.`status` IN (:types)";
+            $parameters['types'] = array_map(
+                function ($type) {
+                    return $type['id'];
+                },
+                array_filter(
+                    SemType::getTypes(),
+                    function ($t) use ($semclass) { return $t['class'] === $semclass; }
+                )
+            );
+        }
+
+        if ($searchterm) {
+            $query .= " AND s.`Name` LIKE :searchterm";
+            $parameters['searchterm'] = '%' . trim($searchterm) . '%';
+        }
+
+        if ($courses) {
+            $query .= " AND t.`seminar_id` IN (:courses)";
+            $parameters['courses'] = $courses;
+        }
+
+        if (Config::get()->IMPORTANT_SEMNUMBER) {
+            $query .= " ORDER BY s.`start_time`, s.`VeranstaltungsNummer`, s.`Name`";
+        } else {
+            $query .= " ORDER BY s.`start_time`, s.`Name`";
+        }
+
+        return DBManager::get()->fetchAll($query, $parameters, 'Course::buildExisting');
+    }
+
+    public function getAncestors(): array
+    {
+        $path = [
+            [
+                'id' => $this->id,
+                'name' => $this->getName(),
+                'classname' => static::class
+            ]
+        ];
+
+        if ($this->parent_id) {
+            $path = array_merge($this->getNode($this->parent_id)->getAncestors(), $path);
+        }
+
+        return $path;
+    }
+
+    private function getDescendantIds()
+    {
+        $ids = [];
+
+        foreach ($this->_children as $child) {
+            $ids = array_merge($ids, [$child->id], $child->getDescendantIds());
+        }
+
+        return $ids;
+    }
+
 }
diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php
index 1b28e2423f8..6c9d7e3c488 100644
--- a/lib/navigation/AdminNavigation.php
+++ b/lib/navigation/AdminNavigation.php
@@ -91,11 +91,11 @@ class AdminNavigation extends Navigation
         $navigation = new Navigation(_('Standort'));
 
         if ($perm->have_perm(Config::get()->RANGE_TREE_ADMIN_PERM ? Config::get()->RANGE_TREE_ADMIN_PERM : 'admin')) {
-            $navigation->addSubNavigation('range_tree', new Navigation(_('Einrichtungshierarchie'), 'admin_range_tree.php'));
+            $navigation->addSubNavigation('range_tree', new Navigation(_('Einrichtungshierarchie'), 'dispatch.php/admin/tree/rangetree'));
         }
 
         if ($perm->have_perm(Config::get()->SEM_TREE_ADMIN_PERM ? Config::get()->SEM_TREE_ADMIN_PERM : 'admin') && $perm->is_fak_admin()) {
-            $navigation->addSubNavigation('sem_tree', new Navigation(_('Veranstaltungshierarchie'), 'admin_sem_tree.php'));
+            $navigation->addSubNavigation('sem_tree', new Navigation(_('Veranstaltungshierarchie'), 'dispatch.php/admin/tree/semtree'));
         }
 
         if ($perm->have_perm(Config::get()->LOCK_RULE_ADMIN_PERM ? Config::get()->LOCK_RULE_ADMIN_PERM : 'admin')) {
diff --git a/public/admin_range_tree.php b/public/admin_range_tree.php
deleted file mode 100644
index 364f4fc95ce..00000000000
--- a/public/admin_range_tree.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-# Lifter002: TODO
-# Lifter007: TODO
-# Lifter003: TODO
-# Lifter010: TODO
-/**
-* Frontend
-*
-*
-*
-* @author       André Noack <andre.noack@data.quest.de>
-* @access       public
-* @modulegroup  admin_modules
-* @module       admin_range_tree
-* @package      Admin
-*/
-// +---------------------------------------------------------------------------+
-// This file is part of Stud.IP
-// admin_range_tree.php
-//
-// Copyright (c) 2002 André Noack <noack@data-quest.de>
-// Suchi & Berg GmbH <info@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 any later version.
-// +---------------------------------------------------------------------------+
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-// You should have received a copy of the GNU General Public License
-// along with this program; if not, write to the Free Software
-// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
-// +---------------------------------------------------------------------------+
-
-
-require '../lib/bootstrap.php';
-
-page_open(["sess" => "Seminar_Session", "auth" => "Seminar_Auth", "perm" => "Seminar_Perm", "user" => "Seminar_User"]);
-$perm->check(Config::get()->RANGE_TREE_ADMIN_PERM ?: 'admin');
-
-include 'lib/seminar_open.php'; //hier werden die sessions initialisiert
-
-PageLayout::setTitle(_('Einrichtungshierarchie bearbeiten'));
-Navigation::activateItem('/admin/locations/range_tree');
-
-ob_start();
-
-$the_tree = new StudipRangeTreeViewAdmin();
-$the_tree->open_ranges['root'] = true;
-$the_tree->showTree();
-
-$template = $GLOBALS['template_factory']->open('layouts/base.php');
-$template->content_for_layout = ob_get_clean();
-echo $template->render();
-
-page_close();
diff --git a/public/admin_sem_tree.php b/public/admin_sem_tree.php
deleted file mode 100644
index e981be7230d..00000000000
--- a/public/admin_sem_tree.php
+++ /dev/null
@@ -1,310 +0,0 @@
-<?php
-# Lifter001: TEST
-# Lifter002: TODO
-# Lifter007: TODO
-# Lifter003: TODO
-# Lifter010: TODO
-// +---------------------------------------------------------------------------+
-// This file is part of Stud.IP
-// admin_sem_tree.php
-//
-//
-// Copyright (c) 2003 André Noack <noack@data-quest.de>
-// Suchi & Berg GmbH <info@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 any later version.
-// +---------------------------------------------------------------------------+
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-// You should have received a copy of the GNU General Public License
-// along with this program; if not, write to the Free Software
-// Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
-// +---------------------------------------------------------------------------+
-
-use Studip\Button, Studip\LinkButton;
-
-require '../lib/bootstrap.php';
-
-page_open(["sess" => "Seminar_Session", "auth" => "Seminar_Auth", "perm" => "Seminar_Perm", "user" => "Seminar_User"]);
-$perm->check(Config::get()->SEM_TREE_ADMIN_PERM ?: 'admin');
-if (!$perm->is_fak_admin()){
-    $perm->perm_invalid(0,0);
-    page_close();
-    die;
-}
-
-include 'lib/seminar_open.php'; // initialise Stud.IP-Session
-
-PageLayout::setTitle(_('Veranstaltungshierachie bearbeiten'));
-Navigation::activateItem('/admin/locations/sem_tree');
-
-// Start of Output
-ob_start();
-
-$view = DbView::getView('sem_tree');
-$the_tree = new StudipSemTreeViewAdmin(Request::option('start_item_id'));
-$search_obj = new StudipSemSearch();
-
-$_open_items =& $the_tree->open_items;
-$_open_ranges =& $the_tree->open_ranges;
-$_possible_open_items = [];
-
-if (!Config::GetInstance()->getValue('SEM_TREE_ALLOW_BRANCH_ASSIGN')){
-    if(is_array($_open_items)){
-        foreach($_open_items as $item_id => $value){
-            if(!$the_tree->tree->getNumKids($item_id)) $_possible_open_items[$item_id] = $value;
-        }
-    }
-} else {
-    $_possible_open_items = $_open_items;
-}
-
-// allow add only for items where user has admin permission and which are not hidden
-if (is_array($_possible_open_items)) {
-    foreach ($_possible_open_items as $item_id => $value) {
-        if (!$the_tree->isItemAdmin($item_id) || $the_tree->tree->isHiddenItem($item_id)) {
-            unset($_possible_open_items[$item_id]);
-        }
-    }
-}
-
-if ($search_obj->search_done){
-    if ($search_obj->search_result->numRows > 50){
-        PageLayout::postError(_("Es wurden mehr als 50 Veranstaltungen gefunden! Bitte schränken Sie Ihre Suche weiter ein."));
-    } elseif ($search_obj->search_result->numRows > 0){
-        PageLayout::postSuccess(sprintf(
-            _("Es wurden %s Veranstaltungen gefunden, und in Ihre Merkliste eingefügt"),
-            $search_obj->search_result->numRows
-        ));
-        if (is_array($_SESSION['_marked_sem']) && count($_SESSION['_marked_sem'])){
-            $_SESSION['_marked_sem'] = array_merge(
-                (array)$_SESSION['_marked_sem'],
-                (array)$search_obj->search_result->getDistinctRows("seminar_id")
-            );
-        } else {
-            $_SESSION['_marked_sem'] = $search_obj->search_result->getDistinctRows("seminar_id");
-        }
-    } else {
-        PageLayout::postInfo(_("Es wurden keine Veranstaltungen gefunden, auf die Ihre Suchkriterien zutreffen."));
-    }
-}
-
-if (Request::option('cmd') === "MarkList"){
-    $sem_mark_list = Request::quotedArray('sem_mark_list');
-    if ($sem_mark_list){
-        if (Request::quoted('mark_list_aktion') == "del"){
-            $count_del = 0;
-            for ($i = 0; $i < count($sem_mark_list); ++$i){
-                if (isset($_SESSION['_marked_sem'][$sem_mark_list[$i]])){
-                    ++$count_del;
-                    unset($_SESSION['_marked_sem'][$sem_mark_list[$i]]);
-                }
-            }
-            PageLayout::postSuccess(sprintf(
-                _("%s Veranstaltung(en) wurde(n) aus Ihrer Merkliste entfernt."),
-                $count_del
-            ));
-        } else {
-            $tmp = explode("_",Request::quoted('mark_list_aktion'));
-            $item_ids[0] = $tmp[1];
-            if ($item_ids[0] === "all"){
-                $item_ids = [];
-                foreach ($_possible_open_items as $key => $value){
-                    if($key !== 'root')
-                        $item_ids[] = $key;
-                }
-            }
-            for ($i = 0; $i < count($item_ids); ++$i){
-                $count_ins = 0;
-                for ($j = 0; $j < count($sem_mark_list); ++$j){
-                    if ($sem_mark_list[$j]){
-                        $count_ins += StudipSemTree::InsertSemEntry($item_ids[$i], $sem_mark_list[$j]);
-                    }
-                }
-                $_msg .= sprintf(
-                    _("%s Veranstaltung(en) in <b>" .htmlReady($the_tree->tree->tree_data[$item_ids[$i]]['name']) . "</b> eingetragen.<br>"),
-                    $count_ins
-                );
-            }
-            if ($_msg) {
-                PageLayout::postSuccess($_msg);
-            }
-            $the_tree->tree->init();
-        }
-    }
-}
-if ($the_tree->mode === "MoveItem" || $the_tree->mode === "CopyItem"){
-    if ($_msg){
-        $_msg .= "§";
-    }
-    if ($the_tree->mode === "MoveItem"){
-        $text = _("Der Verschiebemodus ist aktiviert. Bitte wählen Sie ein Einfügesymbol %s aus, um das Element <b>%s</b> an diese Stelle zu verschieben.%s");
-    } else {
-        $text = _("Der Kopiermodus ist aktiviert. Bitte wählen Sie ein Einfügesymbol %s aus, um das Element <b>%s</b> an diese Stelle zu kopieren.%s");
-    }
-    PageLayout::postInfo(sprintf(
-        $text ,
-        Icon::create('arr_2right', 'sort', ['title' => _('Einfügesymbol')])->asImg(),
-        htmlReady($the_tree->tree->tree_data[$the_tree->move_item_id]['name']),
-        "<div align=\"right\">"
-        . LinkButton::createCancel(
-                _('Abbrechen'),
-                $the_tree->getSelf("cmd=Cancel&item_id=$the_tree->move_item_id"),
-                ['title' => _("Verschieben / Kopieren abbrechen")]
-        )
-        ."</div>"
-    ));
-}
-
-?>
-    <?
-    $search_obj->attributes_default = ['style' => ''];
-    // $search_obj->search_fields['type']['size'] = 30 ;
-    echo $search_obj->getFormStart(URLHelper::getLink($the_tree->getSelf()), ['class' => 'default narrow']);
-    ?>
-    <fieldset>
-        <legend><?= _("Veranstaltungssuche") ?></legend>
-
-        <label class="col-3">
-            <?=_("Titel")?>
-            <?=$search_obj->getSearchField("title")?>
-        </label>
-
-        <label class="col-3">
-            <?=_("Untertitel")?>
-            <?=$search_obj->getSearchField("sub_title")?>
-        </label>
-
-        <label class="col-3">
-            <?=_("Nummer")?>
-            <?=$search_obj->getSearchField("number")?>
-        </label>
-
-        <label class="col-3">
-            <?=_("Kommentar")?>
-            <?=$search_obj->getSearchField("comment")?>
-        </label>
-
-        <label class="col-3">
-            <?=_("Lehrende")?>
-            <?=$search_obj->getSearchField("lecturer")?>
-        </label>
-
-        <label class="col-3">
-            <?=_("Bereich")?>
-            <?=$search_obj->getSearchField("scope")?>
-        </label>
-
-        <label>
-            <?=_("Kombination")?>
-            <?=$search_obj->getSearchField('combination')?>
-        </label>
-
-        <label class="col-3">
-            <?=_("Typ")?>
-            <?=$search_obj->getSearchField("type", ['class' => 'size-s'])?>
-        </label>
-
-        <label class="col-3">
-            <?=_("Semester")?>
-            <?=$search_obj->getSearchField("sem", ['class' => 'size-s'])?>
-        </label>
-    </fieldset>
-
-    <footer>
-        <?=$search_obj->getSearchButton();?>
-        <?=$search_obj->getNewSearchButton();?>
-    </footer>
-
-    <?=$search_obj->getFormEnd();?>
-<br>
-<table width="100%" border="0" cellpadding="0" cellspacing="0">
-    <tr>
-        <td class="blank" width="75%" align="left" valign="top" colspan="2">
-            <? $the_tree->showSemTree(); ?>
-        </td>
-    </tr>
-</table>
-
-<?
-// Create Clipboard (use a second output buffer)
-ob_start();
-?>
-    <form action="<?=URLHelper::getLink($the_tree->getSelf("cmd=MarkList"))?>" method="post" class="default">
-    <?= CSRFProtection::tokenTag() ?>
-        <select multiple size="10" name="sem_mark_list[]" style="font-size:8pt;width:100%" class="nested-select">
-            <?
-            $cols = 50;
-            if (is_array($_SESSION['_marked_sem']) && count($_SESSION['_marked_sem'])){
-                $view->params[0] = array_keys($_SESSION['_marked_sem']);
-                $entries = new DbSnapshot($view->get_query("view:SEMINAR_GET_SEMDATA"));
-                $sem_data = $entries->getGroupedResult("seminar_id");
-                $sem_number = -1;
-                foreach ($sem_data as $seminar_id => $data) {
-                    if ((int)key($data['sem_number']) !== $sem_number){
-                        if ($sem_number !== -1) {
-                            echo '</optgroup>';
-                        }
-                        $sem_number = key($data['sem_number']);
-                        echo "\n<optgroup label=\"" . $the_tree->tree->sem_dates[$sem_number]['name'] . "\">";
-                    }
-                    $sem_name = key($data["Name"]);
-                    $sem_number_end = (int)key($data["sem_number_end"]);
-                    if ($sem_number !== $sem_number_end){
-                        $sem_name .= " (" . $the_tree->tree->sem_dates[$sem_number]['name'] . " - ";
-                        $sem_name .= (($sem_number_end === -1) ? _("unbegrenzt") : $the_tree->tree->sem_dates[$sem_number_end]['name']) . ")";
-                    }
-                    $line = htmlReady(my_substr($sem_name,0,$cols));
-                    $tooltip = $sem_name . " (" . join(",",array_keys($data["doz_name"])) . ")";
-                    echo "\n<option value=\"$seminar_id\" " . tooltip($tooltip,false) . ">$line</option>";
-                }
-                echo '</optgroup>';
-            }
-            ?>
-        </select>
-        <select name="mark_list_aktion" style="font-size:8pt;width:100%;margin-top:5px;">
-            <?
-            if (is_array($_possible_open_items) && count($_possible_open_items) && !(count($_possible_open_items) === 1 && $_possible_open_items['root'])){
-                echo "\n<option  value=\"insert_all\">" . _("Markierte in alle geöffneten Bereiche eintragen") . "</option>";
-                foreach ($_possible_open_items as $item_id => $value){
-                    echo "\n<option value=\"insert_{$item_id}\">"
-                   . sprintf(
-                       _('Markierte in "%s" eintragen'),
-                       htmlReady(my_substr($the_tree->tree->tree_data[$item_id]['name'],0,floor($cols * .8))
-                   ))
-                    . "</option>";
-                }
-            }
-            ?>
-            <option value="del"><?=_("Markierte aus der Merkliste löschen")?></option>
-        </select>
-        <div align="center">
-            <?= Button::create(
-                _('OK'),
-                [
-                    'title' => _("Gewählte Aktion starten"),
-                    'style' => 'vertical-align:middle;margin:3px;',
-                    'class' => 'accept button'
-                ]
-            ); ?>
-        </div>
-    </form>
-<?
-
-// Add Clipboard to Sidebar (get the inner/second output buffer)
-$content = ob_get_clean();
-$widget = new SidebarWidget();
-$widget->setTitle(_('Merkliste'));
-$widget->addElement(new WidgetElement($content));
-Sidebar::get()->addWidget($widget);
-
-$template = $GLOBALS['template_factory']->open('layouts/base.php');
-$template->content_for_layout = ob_get_clean();
-echo $template->render();
-
-page_close();
diff --git a/resources/assets/javascripts/bootstrap/treeview.js b/resources/assets/javascripts/bootstrap/treeview.js
new file mode 100644
index 00000000000..998a70e72ff
--- /dev/null
+++ b/resources/assets/javascripts/bootstrap/treeview.js
@@ -0,0 +1,12 @@
+import StudipTree from '../../../vue/components/tree/StudipTree.vue'
+
+STUDIP.ready(() => {
+    document.querySelectorAll('[data-studip-tree]').forEach(element => {
+        STUDIP.Vue.load().then(({ createApp }) => {
+            createApp({
+                el: element,
+                components: { StudipTree }
+            })
+        })
+    });
+});
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index 5de07aa22e1..d40d4e02b7e 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -83,6 +83,7 @@ import "./bootstrap/cache-admin.js"
 import "./bootstrap/oer.js"
 import "./bootstrap/courseware.js"
 import "./bootstrap/responsive-navigation.js"
+import "./bootstrap/treeview.js"
 
 import "./mvv_course_wizard.js"
 import "./mvv.js"
diff --git a/resources/assets/stylesheets/scss/sidebar.scss b/resources/assets/stylesheets/scss/sidebar.scss
index c009e0e8d07..f03d7a2e8fb 100644
--- a/resources/assets/stylesheets/scss/sidebar.scss
+++ b/resources/assets/stylesheets/scss/sidebar.scss
@@ -330,7 +330,7 @@ select.sidebar-selectlist {
 
                 .reset-search {
                     background-color: transparent;
-                    border: 1px solid $base-color-60;
+                    border: 1px solid var(--dark-gray-color-30);
                     border-left: 0;
                     border-right: 0;
                     display: inline-block;
diff --git a/resources/assets/stylesheets/scss/tree.scss b/resources/assets/stylesheets/scss/tree.scss
new file mode 100644
index 00000000000..dbad68b1bc5
--- /dev/null
+++ b/resources/assets/stylesheets/scss/tree.scss
@@ -0,0 +1,384 @@
+$tree-outline: 1px solid var(--light-gray-color-40);
+
+.studip-tree {
+    &.studip-tree-navigatable {
+        > header {
+            display: flex;
+            flex-direction: row;
+            flex-wrap: nowrap;
+
+            h1 {
+                display: inline-block;
+                width: calc(100% - 28px);
+            }
+        }
+
+        .contentbar {
+            display: relative;
+
+            .contentbar-wrapper-right {
+                display: inherit;
+
+                .action-menu {
+                    button {
+                        top: -2px;
+                    }
+                }
+            }
+        }
+
+        .studip-tree-navigation-wrapper {
+            margin-right: 15px;
+            text-indent: 0;
+
+            .studip-tree-navigation {
+                background-color: var(--white);
+                border: 1px solid var(--content-color-40);
+                box-shadow: 2px 2px mix($base-gray, $white, 20%);
+                right: -20px;
+                padding: 10px;
+                position: absolute;
+                top: -15px;
+                width: 400px;
+                z-index: 3;
+
+                > header {
+                    border-bottom: 1px solid var(--content-color-40);
+                    display: flex;
+                    height: 60px;
+                    margin-bottom: 15px;
+                    margin-top: -15px;
+                    padding: 2px 0;
+
+                    h1 {
+                        line-height:60px;
+                        margin-bottom: 0;
+                        width: calc(100% - 40px);
+                    }
+
+                    button {
+                        flex: 0;
+                        padding-top: 10px;
+                    }
+                }
+
+                .studip-tree-node {
+                    width: 100%;
+                }
+            }
+        }
+    }
+
+    section {
+        margin-left: 0;
+        margin-right: 0;
+    }
+
+    button {
+        background: transparent;
+        border: 0;
+        color: var(--base-color);
+        cursor: pointer;
+        padding: 0;
+
+        &:hover {
+            .studip-tree-child-title {
+                text-decoration: underline;
+            }
+        }
+    }
+
+    .studip-tree-course {
+        .course-dates {
+            color: var(--dark-gray-color-80);
+            font-size: $font-size-small;
+            padding-left: 35px;
+        }
+
+        .course-details {
+            color: var(--dark-gray-color-80);
+            font-size: $font-size-small;
+            text-align: right;
+
+            .admission-state {
+                height: 18px;
+            }
+
+            .course-lecturers {
+                list-style: none;
+                padding-left: 0;
+            }
+        }
+    }
+
+    /* Display as foldable tree */
+    .studip-tree-node {
+
+        width: 100%;
+
+        a {
+            cursor: pointer;
+            display: flex;
+
+            img {
+                vertical-align: bottom;
+            }
+        }
+
+        .studip-tree-node-content {
+
+            display: flex;
+
+            &.studip-tree-node-active {
+                background-color: var(--light-gray-color-20);
+                margin: -5px;
+                padding: 5px;
+            }
+
+            .studip-tree-node-toggle {
+                margin-left: -2px;
+                margin-right: 5px;
+            }
+
+            .tooltip {
+                line-height: 24px;
+                margin-left: 5px;
+            }
+
+            .studip-tree-node-assignment-state {
+                margin-right: 10px;
+
+                img, svg {
+                    vertical-align: text-bottom;
+                }
+            }
+
+            a.studip-tree-node-edit-link {
+                opacity: 0;
+                visibility: hidden;
+
+            }
+
+            &:hover {
+                background-color: var(--light-gray-color-20);
+
+                a.studip-tree-node-edit-link {
+                    opacity: 1;
+                    visibility: visible;
+                }
+            }
+        }
+
+        .studip-tree-children {
+            list-style: none;
+            padding-left: 38px;
+
+            li {
+                border-left: $tree-outline;
+                display: flex;
+                margin-left: -31px;
+                padding: 5px 0 5px 5px;
+
+                &:before {
+                    border-bottom: $tree-outline;
+                    content: "";
+                    display: inline-block;
+                    height: 1em;
+                    left: -5px;
+                    position: relative;
+                    top: -5px;
+                    vertical-align: top;
+                    width: 10px;
+                }
+
+                &:last-child {
+                    border-left: none;
+
+                    &:before {
+                        border-left: $tree-outline;
+                    }
+                }
+            }
+        }
+    }
+
+    > .studip-tree-node {
+        width: calc(100% - 25px);
+    }
+
+    /* Top breadcrumb */
+    .studip-tree-breadcrumb {
+        display: flex;
+        flex-direction: row;
+        flex-wrap: nowrap;
+        max-width: 100%;
+        padding: 1em;
+        top: 2px;
+
+        .contentbar-wrapper-left {
+            max-width: calc(100% - 25px);
+
+            &.with-navigation {
+                max-width: calc(100% - 50px);
+            }
+
+            &.editable {
+                max-width: calc(100% - 50px);
+            }
+
+            &.with-navigation-and-editable {
+                max-width: calc(100% - 75px);
+            }
+
+            img {
+                vertical-align: text-bottom;
+            }
+
+            .studip-tree-breadcrumb-list {
+                display: inline-block;
+                flex: 1;
+                line-height: 24px;
+                margin-left: 15px;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+            }
+        }
+
+        .action-menu {
+            position: relative;
+            top: 5px;
+            width: 30px;
+        }
+
+    }
+
+    /* Display as tiled list */
+    .studip-tree-list {
+        section, nav:not(.contentbar-nav) {
+            padding: 15px;
+        }
+
+        .studip-tree-children {
+            display: grid;
+            grid-gap: 15px;
+            grid-template-columns: repeat(auto-fit, $sidebar-width);
+            list-style: none;
+            overflow-wrap: break-word;
+            padding-left: 0;
+
+            .studip-tree-child {
+                background: var(--dark-gray-color-5);
+                border: solid thin var(--light-gray-color-40);
+                display: flex;
+                height: 130px;
+                padding: 10px;
+
+                /* Handle for drag&drop */
+                .drag-handle {
+                    background-position-y: 8px;
+                }
+
+                a {
+                    display: flex;
+                    flex-direction: column;
+                    padding: 10px;
+                    text-align: left;
+
+                    .studip-tree-child-title {
+                        font-size: 1.1em;
+                        font-weight: bold;
+                    }
+                }
+
+                &:hover {
+                    background: var(--white);
+
+                    button {
+                        .studip-tree-child-title {
+                            color: var(--red);
+                        }
+                    }
+                }
+            }
+        }
+
+        table {
+            tr {
+                td {
+                    line-height: 24px;
+                    padding: 10px;
+                    vertical-align: top;
+
+                    a {
+                        img {
+                            margin-right: 5px;
+                            vertical-align: bottom;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /* Display as table */
+    .studip-tree-table {
+        table {
+            .studip-tree-node-info {
+                font-size: 0.9em;
+                padding: 15px;
+            }
+
+            tbody {
+                tr {
+
+                    &.studip-tree-course {
+                        .course-dates {
+                            padding-left: 0;
+                        }
+                    }
+
+                    td {
+                        line-height: 28px;
+                        padding: 5px;
+                        vertical-align: top;
+
+                        /* Handle for drag&drop */
+                        .drag-handle {
+                            background-position-y: -5px;
+                            padding-right: 10px;
+                        }
+
+                        button {
+                            background: transparent;
+                            border: 0;
+                            color: var(--base-color);
+                            cursor: pointer;
+
+                            &:hover {
+                                text-decoration: underline;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    .studip-tree-course-path {
+        font-size: 0.9em;
+        list-style: none;
+        padding: 5px;
+
+        button {
+            padding: 0;
+        }
+    }
+}
+
+form.default {
+    .studip-tree-node {
+        padding-top: unset !important;
+    }
+}
+
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index e47024bd988..be72f0ab70b 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -98,6 +98,7 @@
 @import "scss/tooltip";
 @import "scss/tfa";
 @import "scss/tour";
+@import "scss/tree";
 @import "scss/typography";
 @import "scss/user-administration";
 @import "scss/wiki";
diff --git a/resources/vue/components/SearchWidget.vue b/resources/vue/components/SearchWidget.vue
new file mode 100644
index 00000000000..d590b009244
--- /dev/null
+++ b/resources/vue/components/SearchWidget.vue
@@ -0,0 +1,56 @@
+<template>
+    <sidebar-widget id="search-widget" class="sidebar-search" :title="$gettext('Suche')">
+        <template #content>
+            <form class="sidebar-search">
+                <ul class="needles">
+                    <li>
+                        <div class="input-group files-search">
+                            <input type="text" id="searchterm" name="searchterm" v-model="searchterm"
+                                   :placeholder="$gettext('Veranstaltung suchen')"
+                                   :aria-label="$gettext('Veranstaltung suchen')">
+                            <a v-if="isActive" @click.prevent="cancelSearch" class="reset-search">
+                                <studip-icon shape="decline" size="20"></studip-icon>
+                            </a>
+                            <button type="submit" class="submit-search" :title="$gettext('Suchen')"
+                                    @click.prevent="doSearch">
+                                <studip-icon shape="search" :size="20"></studip-icon>
+                            </button>
+                        </div>
+                    </li>
+                </ul>
+            </form>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from './SidebarWidget.vue';
+import StudipIcon from './StudipIcon.vue';
+
+export default {
+    name: 'search-widget',
+    components: {
+        StudipIcon,
+        SidebarWidget
+    },
+    data() {
+        return {
+            searchterm: '',
+            isActive: false
+        };
+    },
+    methods: {
+        doSearch() {
+            if (this.searchterm !== '') {
+                this.isActive = true;
+                STUDIP.eventBus.emit('do-search', this.searchterm);
+            }
+        },
+        cancelSearch() {
+            this.isActive = false;
+            this.searchterm = '';
+            STUDIP.eventBus.emit('cancel-search');
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/StudipProgressIndicator.vue b/resources/vue/components/StudipProgressIndicator.vue
index b6f7ee23537..62a33a8840f 100644
--- a/resources/vue/components/StudipProgressIndicator.vue
+++ b/resources/vue/components/StudipProgressIndicator.vue
@@ -31,7 +31,7 @@ export default {
             };
         },
         hasDescription () {
-            return this.description.trim().length > 0;
+            return this.description?.trim().length > 0;
         }
     }
 }
diff --git a/resources/vue/components/tree/AssignLinkWidget.vue b/resources/vue/components/tree/AssignLinkWidget.vue
new file mode 100644
index 00000000000..fec478f43b9
--- /dev/null
+++ b/resources/vue/components/tree/AssignLinkWidget.vue
@@ -0,0 +1,46 @@
+<template>
+    <sidebar-widget v-if="node" id="assignwidget" class="sidebar-assign" :title="$gettext('Zuordnung')">
+        <template #content>
+            <a :href="assignUrl" :title="$gettext('Angezeigte Veranstaltungen zuordnen')"
+               @click.prevent="assignCurrentCourses">
+                <studip-icon shape="arr_2right"></studip-icon>
+                {{ $gettext('Angezeigte Veranstaltungen zuordnen') }}
+            </a>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+import StudipIcon from '../StudipIcon.vue';
+import { TreeMixin } from '../../mixins/TreeMixin';
+
+export default {
+    name: 'AssignLinkWidget',
+    components: { SidebarWidget, StudipIcon },
+    mixins: [ TreeMixin ],
+    props: {
+        node: {
+            type: String,
+            required: true
+        },
+        courses: {
+            type: Array,
+            default: () => []
+        }
+    },
+    computed: {
+        assignUrl() {
+            return STUDIP.URLHelper.getURL('dispatch.php/admin/tree/batch_assign_semtree');
+        }
+    },
+    methods: {
+        assignCurrentCourses() {
+            STUDIP.Dialog.fromURL(this.assignUrl, { data: {
+                assign_semtree: this.courses.map(course => course.id),
+                return: window.location.href
+            }});
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTree.vue b/resources/vue/components/tree/StudipTree.vue
new file mode 100644
index 00000000000..ff493739f98
--- /dev/null
+++ b/resources/vue/components/tree/StudipTree.vue
@@ -0,0 +1,214 @@
+<template>
+    <div>
+        <div v-if="!isSearching"
+             class="studip-tree" :class="{'studip-tree-navigatable': showStructureAsNavigation}">
+            <studip-progress-indicator v-if="isLoading" :size="48"></studip-progress-indicator>
+            <studip-tree-list v-if="viewType === 'list' && startNode"  :with-children="withChildren"
+                              :visible-children-only="visibleChildrenOnly"
+                              :with-courses="withCourses" :semester="semester" :sem-class="semClass" :node="startNode"
+                              :breadcrumb-icon="breadcrumbIcon" :editable="editable" :edit-url="editUrl"
+                              :create-url="createUrl" :delete-url="deleteUrl" :with-export="withExport"
+                              :show-structure-as-navigation="showStructureAsNavigation" :assignable="assignable"
+                              :with-course-assign="withCourseAssign"
+                              @change-current-node="changeCurrentNode"></studip-tree-list>
+            <studip-tree-table v-else-if="viewType === 'table' && startNode" :with-children="withChildren"
+                               :visible-children-only="visibleChildrenOnly"
+                               :with-courses="withCourses" :semester="semester" :sem-class="semClass" :node="startNode"
+                               :breadcrumb-icon="breadcrumbIcon" :editable="editable" :edit-url="editUrl"
+                               :create-url="createUrl" :delete-url="deleteUrl" :with-export="withExport"
+                               :show-structure-as-navigation="showStructureAsNavigation" :assignable="assignable"
+                               :with-course-assign="withCourseAssign"
+                               @change-current-node="changeCurrentNode"></studip-tree-table>
+            <studip-tree-node v-else-if="viewType === 'tree' && startNode" :with-info="withInfo"
+                              :visible-children-only="visibleChildrenOnly" :node="startNode"
+                              :open-levels="openLevels" :openNodes="openNodes" :breadcrumb-icon="breadcrumbIcon"
+                              :editable="editable" :edit-url="editUrl" :create-url="createUrl" :delete-url="deleteUrl"
+                              :assignable="assignable" :assign-leaves-only="assignLeavesOnly"
+                              :not-assignable-nodes="notAssignableNodes"></studip-tree-node>
+
+        </div>
+        <div v-else class="studip-tree">
+            <tree-search-result :search-config="searchConfig"></tree-search-result>
+        </div>
+        <MountingPortal v-if="withSearch" mountTo="#search-widget" name="sidebar-search">
+            <search-widget></search-widget>
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import axios from 'axios';
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import SearchWidget from '../SearchWidget.vue';
+import StudipTreeList from './StudipTreeList.vue';
+import StudipTreeTable from './StudipTreeTable.vue';
+import StudipTreeNode from './StudipTreeNode.vue';
+import TreeSearchResult from './TreeSearchResult.vue';
+
+export default {
+    name: 'StudipTree',
+    components: {
+        TreeSearchResult, SearchWidget, StudipProgressIndicator, StudipTreeList, StudipTreeTable, StudipTreeNode
+    },
+    mixins: [ TreeMixin ],
+    props: {
+        viewType: {
+            type: String,
+            default: 'tree'
+        },
+        treeId: {
+            type: String,
+            default: ''
+        },
+        startId: {
+            type: String,
+            required: true
+        },
+        title: {
+            type: String,
+            default: ''
+        },
+        openNodes: {
+            type: Array,
+            default: () => []
+        },
+        openLevels: {
+            type: Number,
+            default: 0
+        },
+        withChildren: {
+            type: Boolean,
+            default: true
+        },
+        withInfo: {
+            type: Boolean,
+            default: true
+        },
+        visibleChildrenOnly: {
+            type: Boolean,
+            default: true
+        },
+        withCourses: {
+            type: Boolean,
+            default: false
+        },
+        semester: {
+            type: String,
+            default: ''
+        },
+        semClass: {
+            type: Number,
+            default: 0
+        },
+        breadcrumbIcon: {
+            type: String,
+            default: 'literature'
+        },
+        itemIcon: {
+            type: String,
+            default: 'literature'
+        },
+        withSearch: {
+            type: Boolean,
+            default: false
+        },
+        withExport: {
+            type: Boolean,
+            default: false
+        },
+        withCourseAssign: {
+            type: Boolean,
+            default: false
+        },
+        editable: {
+            type: Boolean,
+            default: false
+        },
+        editUrl: {
+            type: String,
+            default: ''
+        },
+        createUrl: {
+            type: String,
+            default: ''
+        },
+        deleteUrl: {
+            type: String,
+            default: ''
+        },
+        showStructureAsNavigation: {
+            type: Boolean,
+            default: false
+        },
+        assignable: {
+            type: Boolean,
+            default: false
+        },
+        assignLeavesOnly: {
+            type: Boolean,
+            default: false
+        },
+        notAssignableNodes: {
+            type: Array,
+            default: () => []
+        }
+    },
+    data() {
+        return {
+            nodeId: this.startId,
+            startNode: null,
+            currentNode: this.startNode,
+            loaded: false,
+            isLoading: false,
+            showStructuralNavigation: false,
+            searchConfig: {},
+            isSearching: false
+        }
+    },
+    methods: {
+        changeCurrentNode(node) {
+            this.currentNode = node;
+            this.$nextTick(() => {
+                document.getElementById('tree-breadcrumb-' + node.attributes.id)?.focus();
+            });
+        },
+        exportUrl() {
+            return STUDIP.URLHelper.getURL('dispatch.php/tree/export_csv');
+        }
+    },
+    mounted() {
+        window.focus();
+
+        const loadingIndicator = axios.interceptors.request.use(config => {
+            setTimeout(() => {
+                if (!this.loaded) {
+                    this.isLoading = true;
+                }
+            }, this.showProgressIndicatorTimeout);
+            return config;
+        });
+
+        this.getNode(this.startId).then(response => {
+            this.startNode = response.data.data;
+            this.currentNode = this.startNode;
+            this.loaded = true;
+            this.isLoading = false;
+        });
+
+        axios.interceptors.request.eject(loadingIndicator);
+
+        this.globalOn('do-search', searchterm => {
+            this.searchConfig.searchterm = searchterm;
+            this.searchConfig.semester = this.semester;
+            this.searchConfig.classname = this.startNode.attributes.classname;
+            this.isSearching = true;
+        });
+
+        this.globalOn('cancel-search', () => {
+            this.searchConfig = {};
+            this.isSearching = false;
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTreeList.vue b/resources/vue/components/tree/StudipTreeList.vue
new file mode 100644
index 00000000000..d773f433f89
--- /dev/null
+++ b/resources/vue/components/tree/StudipTreeList.vue
@@ -0,0 +1,359 @@
+<template>
+    <article class="studip-tree-list">
+        <header>
+            <tree-breadcrumb v-if="currentNode.id !== 'root'" :node="currentNode"
+                             :edit-url="editUrl" :icon="breadcrumbIcon" :assignable="assignable"
+                             :num-children="children.length" :num-courses="courses.length"
+                             :show-navigation="showStructureAsNavigation"
+                             :visible-children-only="visibleChildrenOnly"></tree-breadcrumb>
+        </header>
+        <studip-progress-indicator v-if="isLoading"></studip-progress-indicator>
+        <section v-else>
+            <h1>
+                {{ currentNode.attributes.name }}
+                <a v-if="isEditable && currentNode.attributes.id !== 'root'"
+                   :href="editUrl + '/' + currentNode.attributes.id"
+                   @click.prevent="editNode(editUrl, currentNode.id)" data-dialog="size=medium"
+                   :title="$gettextInterpolate($gettext('%{name} bearbeiten'), {name: currentNode.attributes.name})">
+                    <studip-icon shape="edit" :size="20"></studip-icon>
+                </a>
+            </h1>
+            <p v-if="currentNode.attributes.description?.trim() !== ''" class="studip-tree-node-info"
+               v-html="currentNode.attributes['description-formatted']">
+            </p>
+        </section>
+
+        <span aria-live="assertive" class="sr-only">{{ assistiveLive }}</span>
+
+        <nav v-if="withChildren && currentNode.attributes['has-children']" >
+            <h1>
+                {{ $gettext('Unterebenen') }}
+            </h1>
+            <draggable v-model="children" handle=".drag-handle" :animation="300" tag="ul"
+                       class="studip-tree-children" @end="dropChild">
+                <li v-for="(child, index) in children" :key="index" class="studip-tree-child">
+                    <a v-if="editable && children.length > 1" class="drag-link"
+                       tabindex="0"
+                       :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name})"
+                       @keydown="keyHandler($event, index)"
+                       :ref="'draghandle-' + index">
+                        <span class="drag-handle"></span>
+                    </a>
+                    <tree-node-tile :node="child" :semester="withCourses ? semester : 'all'" :sem-class="semClass"
+                                    :url="nodeUrl(child.id, semester !== 'all' ? semester : null)"></tree-node-tile>
+                </li>
+            </draggable>
+        </nav>
+        <section v-else-if="withChildren && !currentNode.attributes['has-children']"  class="studip-tree-node-no-children">
+            {{ $gettext('Auf dieser Ebene existieren keine weiteren Unterebenen.') }}
+        </section>
+        <section v-if="withCourses && thisLevelCourses === 0" class="studip-tree-node-no-courses">
+            {{ $gettext('Auf dieser Ebene sind keine Veranstaltungen zugeordnet.')}}
+        </section>
+
+        <section v-if="thisLevelCourses + subLevelsCourses > 0">
+            <span v-if="withCourses && showingAllCourses">
+                <button type="button" @click="showAllCourses(false)"
+                        :title="$gettext('Veranstaltungen auf dieser Ebene anzeigen')">
+                    Veranstaltungen auf dieser Ebene anzeigen
+                </button>
+            </span>
+            <template v-if="thisLevelCourses > 0 && subLevelsCourses > 0">
+                |
+            </template>
+            <span v-if="withCourses && subLevelsCourses > 0 && !showingAllCourses">
+                <button type="button" @click="showAllCourses(true)"
+                        :title="$gettext('Veranstaltungen auf allen Unterebenen anzeigen')">
+                    Veranstaltungen auf allen Unterebenen anzeigen
+                </button>
+            </span>
+        </section>
+        <table v-if="courses.length > 0" class="default">
+            <caption>{{ $gettext('Veranstaltungen') }}</caption>
+            <colgroup>
+                <col>
+                <col>
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>{{ $gettext('Name') }}</th>
+                    <th>{{ $gettext('Information') }}</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="(course) in courses" :key="course.id" class="studip-tree-child studip-tree-course">
+                    <td>
+                        <a :href="courseUrl(course.id)"
+                           :title="$gettextInterpolate($gettext('Zur Veranstaltung %{ course }'),
+                                { course: course.attributes.title })">
+                            <studip-icon shape="seminar" :size="26"></studip-icon>
+                            <template v-if="course.attributes['course-number']">
+                                {{ course.attributes['course-number'] }}
+                            </template>
+                            {{ course.attributes.title }}
+                        </a>
+                        <div :id="'course-dates-' + course.id" class="course-dates"></div>
+                    </td>
+                    <td>
+                        <tree-course-details :course="course.id"></tree-course-details>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <MountingPortal v-if="withExport" mountTo="#export-widget" name="sidebar-export">
+            <tree-export-widget v-if="courses.length > 0"
+                                :title="$gettext('Veranstaltungen exportieren')" :url="exportUrl()"
+                                :export-data="courses"></tree-export-widget>
+        </MountingPortal>
+        <MountingPortal v-if="withCourseAssign" mountTo="#assign-widget" name="sidebar-assign-courses">
+            <assign-link-widget v-if="courses.length > 0" :node="currentNode" :courses="courses"></assign-link-widget>
+        </MountingPortal>
+    </article>
+</template>
+
+<script>
+import draggable from 'vuedraggable';
+import { TreeMixin } from '../../mixins/TreeMixin';
+import TreeExportWidget from './TreeExportWidget.vue';
+import TreeBreadcrumb from './TreeBreadcrumb.vue';
+import TreeNodeTile from './TreeNodeTile.vue';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import TreeCourseDetails from './TreeCourseDetails.vue';
+import AssignLinkWidget from "./AssignLinkWidget.vue";
+
+export default {
+    name: 'StudipTreeList',
+    components: {
+        draggable, StudipProgressIndicator, TreeExportWidget, TreeBreadcrumb, TreeNodeTile, TreeCourseDetails,
+        AssignLinkWidget
+    },
+    mixins: [ TreeMixin ],
+    props: {
+        node: {
+            type: Object,
+            required: true
+        },
+        breadcrumbIcon: {
+            type: String,
+            default: 'literature'
+        },
+        editable: {
+            type: Boolean,
+            default: false
+        },
+        editUrl: {
+            type: String,
+            default: ''
+        },
+        createUrl: {
+            type: String,
+            default: ''
+        },
+        deleteUrl: {
+            type: String,
+            default: ''
+        },
+        withCourses: {
+            type: Boolean,
+            default: false
+        },
+        withExport: {
+            type: Boolean,
+            default: false
+        },
+        withChildren: {
+            type: Boolean,
+            default: true
+        },
+        visibleChildrenOnly: {
+            type: Boolean,
+            default: true
+        },
+        assignable: {
+            type: Boolean,
+            default: false
+        },
+        withCourseAssign: {
+            type: Boolean,
+            default: false
+        },
+        semester: {
+            type: String,
+            default: ''
+        },
+        semClass: {
+            type: Number,
+            default: 0
+        },
+        showStructureAsNavigation: {
+            type: Boolean,
+            default: false
+        }
+    },
+    data() {
+        return {
+            currentNode: this.node,
+            isLoading: false,
+            isLoaded: false,
+            children: [],
+            courses: [],
+            assistiveLive: '',
+            subLevelsCourses: 0,
+            thisLevelCourses: 0,
+            showingAllCourses: false
+        }
+    },
+    methods: {
+        openNode(node, pushState = true) {
+            this.currentNode = node;
+            this.$emit('change-current-node', node);
+
+            if (this.withChildren) {
+                this.getNodeChildren(node, this.visibleChildrenOnly).then(response => {
+                    this.children = response.data.data;
+                });
+            }
+
+            this.getNodeCourseInfo(node, this.semester, this.semClass)
+                .then(response => {
+                    this.thisLevelCourses = response?.data.courses;
+                    this.subLevelsCourses = response?.data.allCourses;
+                });
+
+            if (this.withCourses) {
+                this.getNodeCourses(node, this.semester, this.semClass, '', false)
+                    .then(courses => {
+                        this.courses = courses.data.data;
+                    });
+            }
+
+            // Update browser history.
+            if (pushState) {
+                const nodeId = node.id;
+                const url = STUDIP.URLHelper.getURL('', {node_id: nodeId});
+                window.history.pushState({nodeId}, '', url);
+            }
+
+            // Update node_id for semester selector.
+            const semesterSelector = document.querySelector('#semester-selector-node-id');
+            semesterSelector.value = node.id;
+        },
+        dropChild() {
+            this.updateSorting(this.currentNode.id, this.children);
+        },
+        keyHandler(e, index) {
+            switch (e.keyCode) {
+                case 38: // up
+                    e.preventDefault();
+                    this.decreasePosition(index);
+                    this.$nextTick(() => {
+                        this.$refs['draghandle-' + (index - 1)][0].focus();
+                        this.assistiveLive = this.$gettextInterpolate(
+                            this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+                            { pos: index, listLength: this.children.length }
+                        );
+                    });
+                    break;
+                case 40: // down
+                    e.preventDefault();
+                    this.increasePosition(index);
+                    this.$nextTick(function () {
+                        this.$refs['draghandle-' + (index + 1)][0].focus();
+                        this.assistiveLive = this.$gettextInterpolate(
+                            this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+                            { pos: index + 2, listLength: this.children.length }
+                        );
+                    });
+                    break;
+            }
+        },
+        decreasePosition(index) {
+            if (index > 0) {
+                const temp = this.children[index - 1];
+                this.children[index - 1] = this.children[index];
+                this.children[index] = temp;
+                this.updateSorting(this.currentNode.id, this.children);
+            }
+        },
+        increasePosition(index) {
+            if (index < this.children.length) {
+                const temp = this.children[index + 1];
+                this.children[index + 1] = this.children[index];
+                this.children[index] = temp;
+                this.updateSorting(this.currentNode.id, this.children);
+            }
+        },
+        showAllCourses(state) {
+            this.getNodeCourses(this.currentNode, this.semester, this.semClass, '', state)
+                .then(courses => {
+                    this.courses = courses.data.data;
+                    this.showingAllCourses = state;
+                });
+        }
+    },
+    mounted() {
+        if (this.withChildren) {
+            this.getNodeChildren(this.currentNode, this.visibleChildrenOnly).then(response => {
+                this.children = response.data.data;
+            });
+        }
+
+        this.getNodeCourseInfo(this.currentNode, this.semester, this.semClass)
+            .then(response => {
+                this.thisLevelCourses = response?.data.courses;
+                this.subLevelsCourses = response?.data.allCourses;
+            });
+
+        if (this.withCourses) {
+            this.getNodeCourses(this.currentNode, this.semester, this.semClass)
+                .then(courses => {
+                    this.courses = courses.data.data;
+                });
+        }
+
+        this.globalOn('open-tree-node', node => {
+            this.openNode(node);
+        });
+
+        this.globalOn('load-tree-node', id => {
+            this.getNode(id).then(response => {
+                this.openNode(response.data.data);
+            });
+        });
+
+        this.globalOn('sort-tree-children', data => {
+            if (this.currentNode.id === data.parent) {
+                this.children = data.children;
+            }
+        });
+
+        window.addEventListener('popstate', (event) => {
+            if (event.state) {
+                if ('nodeId' in event.state) {
+                    this.getNode(event.state.nodeId).then(response => {
+                        this.openNode(response.data.data, false);
+                    });
+                }
+            } else {
+                this.openNode(this.node, false);
+            }
+        });
+
+        // Add current node to semester selector widget.
+        this.$nextTick(() => {
+            const semesterForm = document.querySelector('#semester-selector .sidebar-widget-content form');
+            const nodeField = document.createElement('input');
+            nodeField.id = 'semester-selector-node-id';
+            nodeField.type = 'hidden';
+            nodeField.name = 'node_id';
+            nodeField.value = this.node.id;
+            semesterForm.appendChild(nodeField);
+        });
+    },
+    beforeDestroy() {
+        STUDIP.eventBus.off('open-tree-node');
+        STUDIP.eventBus.off('load-tree-node');
+        STUDIP.eventBus.off('sort-tree-children');
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTreeNode.vue b/resources/vue/components/tree/StudipTreeNode.vue
new file mode 100644
index 00000000000..39f696ccc14
--- /dev/null
+++ b/resources/vue/components/tree/StudipTreeNode.vue
@@ -0,0 +1,277 @@
+<template>
+    <section class="studip-tree-node">
+        <span :class="{ 'studip-tree-node-content': true, 'studip-tree-node-active': node?.id === activeNode?.id }">
+            <a @click.prevent="toggleNode(true)">
+                <div v-if="node.attributes['has-children']" class="studip-tree-node-toggle">
+                    <studip-icon :shape="openState ? 'arr_1down': 'arr_1right'" :size="20"/>
+                </div>
+            </a>
+            <button v-if="isAssignable && node.attributes.id !== 'root'" class="studip-tree-node-assignment-state"
+                    @click.prevent="changeAssignmentState()" :title="$gettext('Zuordnung ändern')">
+                <studip-icon :shape="assignmentState === 0
+                    ? 'checkbox-unchecked'
+                    : (assignmentState === 1 ? 'checkbox-checked' : 'checkbox-indeterminate')"></studip-icon>
+            </button>
+            <a @click.prevent="toggleNode(true)">
+                <div class="studip-tree-node-name">
+                    {{ node.attributes.name }}
+                </div>
+            </a>
+            <studip-tooltip-icon v-if="withInfo && !isLoading && node.attributes.description?.trim() !== ''"
+                                 :text="node.attributes['description-formatted'].trim()"></studip-tooltip-icon>
+            <input v-if="isAssignable && node.attributes.id !== 'root'" type="hidden" :name="assignmentAction"
+                   :value="node.attributes.id">
+            <a v-if="editable && node.attributes.id !== 'root'" :href="editUrl + '/' + node.attributes.id"
+               @click.prevent="editNode(editUrl, node.id)" data-dialog="size=medium"
+               class="studip-tree-node-edit-link">
+                <studip-icon shape="edit"></studip-icon>
+            </a>
+        </span>
+        <div v-if="isLoading" class="studip-spinner">
+            <studip-asset-img file="ajax-indicator-black.svg" width="20"/>
+            {{ $gettext('Daten werden geladen...' )}}
+        </div>
+        <ul v-if="node.attributes['has-children'] && openState" class="studip-tree-children">
+            <li v-for="(child) in children" :key="child.id" >
+                <studip-tree-node :node="child" :editable="editable" :edit-url="editUrl" :create-url="createUrl"
+                                  :delete-url="deleteUrl" :assignable="assignable" :ancestors="theAncestors"
+                                  :not-assignable-nodes="notAssignableNodes" :open-nodes="openNodes"
+                                  :open-levels="openLevels > 0 ? (openLevels - 1) : 0"
+                                  :visible-children-only="visibleChildrenOnly"
+                                  :active-node="activeNode" :with-info="withInfo"></studip-tree-node>
+            </li>
+        </ul>
+    </section>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipIcon from '../StudipIcon.vue';
+import StudipAssetImg from '../StudipAssetImg.vue';
+import axios from 'axios';
+import StudipTooltipIcon from '../StudipTooltipIcon.vue';
+
+export default {
+    name: 'StudipTreeNode',
+    components: { StudipTooltipIcon, StudipAssetImg, StudipIcon },
+    mixins: [ TreeMixin ],
+    props: {
+        node: {
+            type: Object,
+            required: true
+        },
+        activeNode: {
+            type: Object,
+            default: null
+        },
+        isOpen: {
+            type: Boolean,
+            default: false
+        },
+        breadcrumbIcon: {
+            type: String,
+            default: 'literature'
+        },
+        withInfo: {
+            type: Boolean,
+            default: true
+        },
+        editable: {
+            type: Boolean,
+            default: false
+        },
+        editUrl: {
+            type: String,
+            default: ''
+        },
+        createUrl: {
+            type: String,
+            default: ''
+        },
+        deleteUrl: {
+            type: String,
+            default: ''
+        },
+        visibleChildrenOnly: {
+            type: Boolean,
+            default: true
+        },
+        withCourses: {
+            type: Boolean,
+            default: true
+        },
+        assignable: {
+            type: Boolean,
+            default: false
+        },
+        assignLeavesOnly: {
+            type: Boolean,
+            default: false
+        },
+        notAssignableNodes: {
+            type: Array,
+            default: () => []
+        },
+        openLevels: {
+            type: Number,
+            default: 0
+        },
+        openNodes: {
+            type: Array,
+            default: () => []
+        },
+        ancestors: {
+            type: Array,
+            default: () => []
+        }
+    },
+    data() {
+        return {
+            isLoading: false,
+            childrenLoaded: false,
+            children: [],
+            semester: 'all',
+            openState: this.isOpen,
+            theAncestors: this.ancestors,
+            assignedCourses: 0,
+            assignmentState: 0,
+            assignmentAction: ''
+        }
+    },
+    methods: {
+        toggleNode(emitEvent = false) {
+            this.courses = [];
+            this.openState = !this.openState;
+            if (emitEvent) {
+                STUDIP.eventBus.emit('load-tree-node', this.node.id);
+            }
+            if (!this.childrenLoaded) {
+                this.children = [];
+                const loadingIndicator = axios.interceptors.request.use(config => {
+                    setTimeout(() => {
+                        if (!this.childrenLoaded) {
+                            this.isLoading = true;
+                        }
+                    }, 500);
+                    return config;
+                });
+                this.getNodeChildren(this.node, this.visibleChildrenOnly)
+                    .then(response => {
+                        this.isLoading = false;
+                        this.children = response.data.data;
+                        this.childrenLoaded = true;
+                    });
+                axios.interceptors.request.eject(loadingIndicator);
+            }
+        },
+        /**
+         * Check whether currently selected course are assigned to this node.
+         */
+        checkAssignments() {
+            const courses = document.querySelectorAll('table.selected-courses input[name="courses[]"]') ?? [];
+            let ids = [];
+            for (const course of courses) {
+                ids.push(course.value);
+            }
+
+            if (ids.length > 0) {
+                this.getNodeCourses(this.node, 'all', 0, '', false, ids)
+                    .then(response => {
+                        // None of the given courses are assigned here.
+                        if (response.data.data.length === 0) {
+                            this.assignedCourses = this.assignmentState = 0;
+                        // All of the given courses are assigned here.
+                        } else if (response.data.data.length === ids.length) {
+                            this.assignedCourses = this.assignmentState = 1;
+                        // Some of the given courses are assigned here.
+                        } else {
+                            this.assignedCourses = this.assignmentState = -1;
+                        }
+                    });
+            }
+        },
+        /**
+         * Change what shall be done on submitting the form.
+         */
+        changeAssignmentState() {
+            // Current state is 0 -> remove all assignments.
+            if (this.assignmentState === 0) {
+                // Not all courses are assigned here -> next state is indeterminate.
+                if (this.assignedCourses === -1) {
+                    this.assignmentState = -1;
+                // Next state is 1 -> add assignments here.
+                } else {
+                    this.assignmentState = 1;
+                }
+            // Current state is 1 -> next state is 0 -> remove assignments here.
+            } else if (this.assignmentState === 1) {
+                this.assignmentState = 0;
+            // Current state is indeterminate -> next state is 1 -> add assignments here.
+            } else {
+                this.assignmentState = 1;
+            }
+
+            // Current state returned to original, nothing needs to be done.
+            if (this.assignmentState === this.assignedCourses) {
+                this.assignmentAction = '';
+            // Current state is different from original state -> add or remove.
+            } else {
+                switch (this.assignmentState) {
+                    case 0:
+                        this.assignmentAction = 'delete_assignments[]';
+                        break;
+                    case 1:
+                        this.assignmentAction = 'add_assignments[]';
+                        break;
+                }
+            }
+
+        }
+    },
+    computed: {
+        isAssignable() {
+            return this.assignable && !this.notAssignableNodes?.includes(this.node.id);
+        }
+    },
+    mounted() {
+        if (this.openLevels > 0) {
+            this.toggleNode();
+        }
+
+        if (this.ancestors.length === 0) {
+            for (const open of this.openNodes) {
+                this.getNode(open).then((response) => {
+                    const haystack = response.data.data.attributes.ancestors?.map(element => {
+                        return element.classname + '_' + element.id;
+                    });
+                    if (haystack) {
+                        this.theAncestors = haystack;
+                        if (this.theAncestors.includes(this.node.id)) {
+                            this.toggleNode();
+                        }
+                    }
+                });
+
+            }
+        }
+
+        this.globalOn('sort-tree-children', data => {
+            if (this.node.id === data.parent) {
+                this.children = data.children;
+            }
+        });
+
+        this.$nextTick(() => {
+            if (this.theAncestors?.includes(this.node.id) && !this.openState) {
+                this.toggleNode();
+            }
+            if (this.isAssignable && this.node.attributes.id !== 'root') {
+                this.checkAssignments();
+            }
+        });
+    },
+    beforeDestroy() {
+        STUDIP.eventBus.off('sort-tree-children');
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/StudipTreeTable.vue b/resources/vue/components/tree/StudipTreeTable.vue
new file mode 100644
index 00000000000..0bfc244fb75
--- /dev/null
+++ b/resources/vue/components/tree/StudipTreeTable.vue
@@ -0,0 +1,377 @@
+<template>
+    <div v-if="isLoading">
+        <studip-progress-indicator></studip-progress-indicator>
+    </div>
+    <article v-else class="studip-tree-table">
+        <header>
+            <tree-breadcrumb v-if="currentNode.id !== 'root'" :node="currentNode"
+                             :icon="breadcrumbIcon" :editable="editable" :edit-url="editUrl" :create-url="createUrl"
+                             :delete-url="deleteUrl" :show-navigation="showStructureAsNavigation"
+                             :num-children="children.length" :num-courses="courses.length"
+                             :assignable="assignable" :visible-children-only="visibleChildrenOnly"></tree-breadcrumb>
+        </header>
+        <section v-if="withChildren && !currentNode.attributes['has-children']" class="studip-tree-node-no-children">
+            {{ $gettext('Auf dieser Ebene existieren keine weiteren Unterebenen.')}}
+        </section>
+
+        <span aria-live="assertive" class="sr-only">{{ assistiveLive }}</span>
+
+        <div v-if="currentNode.attributes.description?.trim() !== ''"
+             v-html="currentNode.attributes['description-formatted']"></div>
+
+        <section v-if="thisLevelCourses === 0" class="studip-tree-node-no-courses">
+            {{ $gettext('Auf dieser Ebene sind keine Veranstaltungen zugeordnet.')}}
+        </section>
+
+        <section v-if="thisLevelCourses + subLevelsCourses > 0">
+            <span v-if="withCourses && showingAllCourses">
+                <button type="button" @click="showAllCourses(false)"
+                        :title="$gettext('Veranstaltungen auf dieser Ebene anzeigen')">
+                    {{ $gettext('Veranstaltungen auf dieser Ebene anzeigen') }}
+                </button>
+            </span>
+            <template v-if="thisLevelCourses > 0 && subLevelsCourses > 0">
+                |
+            </template>
+            <span v-if="withCourses && subLevelsCourses > 0 && !showingAllCourses">
+                <button type="button" @click="showAllCourses(true)"
+                        :title="$gettext('Veranstaltungen auf allen Unterebenen anzeigen')">
+                    {{ $gettext('Veranstaltungen auf allen Unterebenen anzeigen') }}
+                </button>
+            </span>
+        </section>
+
+        <table v-if="currentNode.attributes['has-children'] || courses.length > 0" class="default">
+            <caption class="studip-tree-node-info">
+                <span v-if="withChildren && children.length > 0">
+                    {{ $gettextInterpolate($gettext('%{ count } Unterebenen'), { count: children.length }) }}
+                </span>
+                <span v-if="withChildren && children.length > 0 && withCourses && courses.length > 0">
+                    ,
+                </span>
+            </caption>
+            <colgroup>
+                <col style="width: 20px">
+                <col style="width: 30px">
+                <col>
+                <col style="width: 40%">
+            </colgroup>
+            <thead>
+                <tr>
+                    <th></th>
+                    <th>{{ $gettext('Typ') }}</th>
+                    <th>{{ $gettext('Name') }}</th>
+                    <th>{{ $gettext('Information') }}</th>
+                </tr>
+            </thead>
+            <draggable v-model="children" handle=".drag-handle" :animation="300"
+                       @end="dropChild" tag="tbody" role="listbox">
+                <tr v-for="(child, index) in children" :key="index" class="studip-tree-child">
+                    <td>
+                        <a v-if="editable && children.length > 1" class="drag-link" role="option"
+                           tabindex="0"
+                           :title="$gettextInterpolate($gettext('Sortierelement für Element %{node}. Drücken Sie die Tasten Pfeil-nach-oben oder Pfeil-nach-unten, um dieses Element in der Liste zu verschieben.'), {node: child.attributes.name})"
+                           @keydown="keyHandler($event, index)"
+                           :ref="'draghandle-' + index">
+                            <span class="drag-handle"></span>
+                        </a>
+                    </td>
+                    <td>
+                        <studip-icon :shape="child.attributes['has-children'] ? 'folder-full' : 'folder-empty'"
+                                     :size="26"></studip-icon>
+                    </td>
+                    <td>
+                        <a :href="nodeUrl(child.id, semester !== 'all' ? semester : null)" tabindex="0"
+                           @click.prevent="openNode(child)"
+                           :title="$gettextInterpolate($gettext('Unterebene %{ node } öffnen'),
+                                { node: node.attributes.name })">
+                            {{ child.attributes.name }}
+                        </a>
+                    </td>
+                    <td>
+                        <tree-node-course-info :node="child" :semester="semester"
+                                               :sem-class="semClass"></tree-node-course-info>
+                    </td>
+                </tr>
+                <tr v-for="(course) in courses" :key="course.id" class="studip-tree-child studip-tree-course">
+                    <td></td>
+                    <td>
+                        <studip-icon shape="seminar" :size="26"></studip-icon>
+                    </td>
+                    <td>
+                        <a :href="courseUrl(course.id)" tabindex="0"
+                           :title="$gettextInterpolate($gettext('Zur Veranstaltung %{ course }'),
+                                { course: course.attributes.title })">
+                            <template v-if="course.attributes['course-number']">
+                                {{ course.attributes['course-number'] }}
+                            </template>
+                            {{ course.attributes.title }}
+                        </a>
+                        <div :id="'course-dates-' + course.id" class="course-dates"></div>
+                    </td>
+                    <td :colspan="editable ? 2 : null">
+                        <tree-course-details :course="course.id"></tree-course-details>
+                    </td>
+                </tr>
+            </draggable>
+        </table>
+        <MountingPortal v-if="withExport" mountTo="#export-widget" name="sidebar-export">
+            <tree-export-widget v-if="courses.length > 0" :title="$gettext('Download des Ergebnisses')" :url="exportUrl()"
+                                :export-data="courses"></tree-export-widget>
+        </MountingPortal>
+        <MountingPortal v-if="withCourseAssign" mountTo="#assign-widget" name="sidebar-assign-courses">
+            <assign-link-widget v-if="courses.length > 0" :node="currentNode" :courses="courses"></assign-link-widget>
+        </MountingPortal>
+    </article>
+</template>
+
+<script>
+import draggable from 'vuedraggable';
+import { TreeMixin } from '../../mixins/TreeMixin';
+import TreeExportWidget from './TreeExportWidget.vue';
+import TreeBreadcrumb from './TreeBreadcrumb.vue';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import StudipIcon from '../StudipIcon.vue';
+import TreeNodeCourseInfo from './TreeNodeCourseInfo.vue';
+import TreeCourseDetails from "./TreeCourseDetails.vue";
+import AssignLinkWidget from "./AssignLinkWidget.vue";
+
+export default {
+    name: 'StudipTreeTable',
+    components: {
+        draggable, TreeExportWidget, TreeCourseDetails, StudipIcon, StudipProgressIndicator, TreeBreadcrumb,
+        TreeNodeCourseInfo, AssignLinkWidget
+    },
+    mixins: [ TreeMixin ],
+    props: {
+        node: {
+            type: Object,
+            required: true
+        },
+        breadcrumbIcon: {
+            type: String,
+            default: 'literature'
+        },
+        editable: {
+            type: Boolean,
+            default: false
+        },
+        editUrl: {
+            type: String,
+            default: ''
+        },
+        createUrl: {
+            type: String,
+            default: ''
+        },
+        deleteUrl: {
+            type: String,
+            default: ''
+        },
+        withCourses: {
+            type: Boolean,
+            default: false
+        },
+        withExport: {
+            type: Boolean,
+            default: false
+        },
+        withChildren: {
+            type: Boolean,
+            default: true
+        },
+        visibleChildrenOnly: {
+            type: Boolean,
+            default: true
+        },
+        assignable: {
+            type: Boolean,
+            default: false
+        },
+        withCourseAssign: {
+            type: Boolean,
+            default: false
+        },
+        semester: {
+            type: String,
+            default: ''
+        },
+        semClass: {
+            type: Number,
+            default: 0
+        },
+        showStructureAsNavigation: {
+            type: Boolean,
+            default: false
+        }
+    },
+    data() {
+        return {
+            currentNode: this.node,
+            isLoading: false,
+            isLoaded: false,
+            children: [],
+            courses: [],
+            assistiveLive: '',
+            subLevelsCourses: 0,
+            thisLevelCourses: 0,
+            showingAllCourses: false
+        }
+    },
+    methods: {
+        openNode(node, pushState = true) {
+            this.currentNode = node;
+            this.$emit('change-current-node', node);
+
+            if (this.withChildren) {
+                this.getNodeChildren(node, this.visibleChildrenOnly).then(response => {
+                    this.children = response.data.data;
+                });
+            }
+
+            this.getNodeCourseInfo(node, this.semester, this.semClass)
+                .then(response => {
+                    this.thisLevelCourses = response?.data.courses;
+                    this.subLevelsCourses = response?.data.allCourses;
+                });
+
+            if (this.withCourses) {
+
+                this.getNodeCourses(node, this.semester, this.semClass, '', false)
+                    .then(response => {
+                        this.courses = response.data.data;
+                    });
+            }
+
+            // Update browser history.
+            if (pushState) {
+                const nodeId = node.id;
+                const url = STUDIP.URLHelper.getURL('', {node_id: nodeId});
+                window.history.pushState({nodeId}, '', url);
+            }
+
+            // Update node_id for semester selector.
+            const semesterSelector = document.querySelector('#semester-selector-node-id');
+            semesterSelector.value = node.id;
+        },
+        dropChild() {
+            this.updateSorting(this.currentNode.id, this.children);
+        },
+        keyHandler(e, index) {
+            switch (e.keyCode) {
+                case 38: // up
+                    e.preventDefault();
+                    this.decreasePosition(index);
+                    this.$nextTick(() => {
+                        this.$refs['draghandle-' + (index - 1)][0].focus();
+                        this.assistiveLive = this.$gettextInterpolate(
+                            this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+                            { pos: index, listLength: this.children.length }
+                        );
+                    });
+                    break;
+                case 40: // down
+                    e.preventDefault();
+                    this.increasePosition(index);
+                    this.$nextTick(function () {
+                        this.$refs['draghandle-' + (index + 1)][0].focus();
+                        this.assistiveLive = this.$gettextInterpolate(
+                            this.$gettext('Aktuelle Position in der Liste: %{pos} von %{listLength}.'),
+                            { pos: index + 2, listLength: this.children.length }
+                        );
+                    });
+                    break;
+            }
+        },
+        decreasePosition(index) {
+            if (index > 0) {
+                const temp = this.children[index - 1];
+                this.children[index - 1] = this.children[index];
+                this.children[index] = temp;
+                this.updateSorting(this.currentNode.id, this.children);
+            }
+        },
+        increasePosition(index) {
+            if (index < this.children.length) {
+                const temp = this.children[index + 1];
+                this.children[index + 1] = this.children[index];
+                this.children[index] = temp;
+                this.updateSorting(this.currentNode.id, this.children);
+            }
+        },
+        showAllCourses(state) {
+            this.getNodeCourses(this.currentNode, this.semester, this.semClass, '', state)
+                .then(courses => {
+                    this.courses = courses.data.data;
+                    this.showingAllCourses = state;
+                });
+        }
+    },
+    mounted() {
+        if (this.withChildren) {
+            this.getNodeChildren(this.node, this.visibleChildrenOnly).then(response => {
+                this.children = response.data.data;
+            });
+        }
+
+        this.getNodeCourseInfo(this.currentNode, this.semester, this.semClass)
+            .then(response => {
+                this.thisLevelCourses = response?.data.courses;
+                this.subLevelsCourses = response?.data.allCourses;
+            });
+
+        if (this.withCourses) {
+            this.getNodeCourses(this.currentNode, this.semester, this.semClass)
+                .then(courses => {
+                    this.courses = courses.data.data;
+                });
+        }
+
+        this.globalOn('open-tree-node', node => {
+            STUDIP.eventBus.emit('cancel-search');
+            this.openNode(node);
+        });
+
+        this.globalOn('load-tree-node', id => {
+            STUDIP.eventBus.emit('cancel-search');
+            this.getNode(id).then(response => {
+                this.openNode(response.data.data);
+            });
+        });
+
+        this.globalOn('sort-tree-children', data => {
+            if (this.currentNode.id === data.parent) {
+                this.children = data.children;
+            }
+        });
+
+        window.addEventListener('popstate', (event) => {
+            if (event.state) {
+                if ('nodeId' in event.state) {
+                    this.getNode(event.state.nodeId).then(response => {
+                        this.openNode(response.data.data, false);
+                    });
+                }
+            } else {
+                this.openNode(this.node, false);
+            }
+        });
+
+        // Add current node to semester selector widget.
+        this.$nextTick(() => {
+            const semesterForm = document.querySelector('#semester-selector .sidebar-widget-content form');
+            const nodeField = document.createElement('input');
+            nodeField.id = 'semester-selector-node-id';
+            nodeField.type = 'hidden';
+            nodeField.name = 'node_id';
+            nodeField.value = this.node.id;
+            semesterForm.appendChild(nodeField);
+        });
+    },
+    beforeDestroy() {
+        STUDIP.eventBus.off('open-tree-node');
+        STUDIP.eventBus.off('load-tree-node');
+        STUDIP.eventBus.off('sort-tree-children');
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeBreadcrumb.vue b/resources/vue/components/tree/TreeBreadcrumb.vue
new file mode 100644
index 00000000000..33b04b3c0a2
--- /dev/null
+++ b/resources/vue/components/tree/TreeBreadcrumb.vue
@@ -0,0 +1,194 @@
+<template>
+    <div class="studip-tree-breadcrumb contentbar">
+        <nav class="contentbar-nav"></nav>
+        <div :class="{'contentbar-wrapper-left': true, 'with-navigation': showNavigation, 'editable': editable,
+                'with-navigation-and-editable': showNavigation && editable}">
+            <studip-icon :shape="icon" :size="24"></studip-icon>
+            <nav v-if="node.attributes.ancestors" class="studip-tree-breadcrumb-list contentbar-nav">
+                <span v-for="(ancestor, index) in node.attributes.ancestors"
+                          :key="ancestor.id">
+                    <a :href="nodeUrl(ancestor.classname + '_' + ancestor.id)" :ref="ancestor.id"
+                       @click.prevent="openNode(ancestor.id, ancestor.classname)" tabindex="0"
+                       :id="'tree-breadcrumb-' + ancestor.id"
+                       :title="$gettextInterpolate($gettext('%{ node } öffnen'), { node: ancestor.name})">
+                        {{ ancestor.name }}
+                    </a>
+                    <template v-if="index !== node.attributes.ancestors.length - 1">
+                        /
+                    </template>
+                </span>
+            </nav>
+        </div>
+        <div class="contentbar-wrapper-right">
+            <div v-if="showNavigation" class="studip-tree-navigation-wrapper">
+                <button type="button" tabindex="0"
+                        :title="navigationOpen ? $gettext('Navigation schließen') : $gettext('Navigation öffnen')"
+                        @click.prevent="toggleNavigation" :aria-expanded="navigationOpen">
+                    <studip-icon shape="table-of-contents" :size="24"></studip-icon>
+                </button>
+                <article class="studip-tree-navigation" v-if="navigationOpen">
+                    <header>
+                        <h1>{{ $gettext('Inhalt') }}</h1>
+                        <button type="button" tabindex="0"
+                                @click.prevent="toggleNavigation">
+                            <studip-icon shape="decline" :size="24"></studip-icon>
+                        </button>
+                    </header>
+                    <studip-tree-node :with-info="false" :node="rootNode" :active-node="node" :open-nodes="[ node.id ]"
+                                      :visible-children-only="visibleChildrenOnly"></studip-tree-node>
+                </article>
+            </div>
+            <button v-if="assignable" type="submit" class="assign-button"
+                    :title="$gettext('Diesen Eintrag zuweisen')">
+                <studip-icon shape="arr_2right" :size="20"></studip-icon>
+            </button>
+            <studip-action-menu v-if="editable" :items="actionMenuItems()"
+                                @add-tree-node="addNode" @edit-tree-node="editNode" @delete-tree-node="deleteNode"/>
+        </div>
+    </div>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipIcon from '../StudipIcon.vue';
+import StudipTreeNode from './StudipTreeNode.vue';
+import axios from 'axios';
+
+export default {
+    name: 'TreeBreadcrumb',
+    components: { StudipIcon, StudipTreeNode },
+    mixins: [ TreeMixin ],
+    props: {
+        node: {
+            type: Object,
+            required: true
+        },
+        icon: {
+            type: String,
+            required: true
+        },
+        editable: {
+            type: Boolean,
+            default: false
+        },
+        editUrl: {
+            type: String,
+            default: ''
+        },
+        createUrl: {
+            type: String,
+            default: ''
+        },
+        deleteUrl: {
+            type: String,
+            default: ''
+        },
+        showNavigation: {
+            type: Boolean,
+            default: false
+        },
+        assignable: {
+            type: Boolean,
+            default: false
+        },
+        numChildren: {
+            type: Number,
+            default: 0
+        },
+        numCourses: {
+            type: Number,
+            default: 0
+        },
+        visibleChildrenOnly: {
+            type: Boolean,
+            default: true
+        }
+    },
+    data() {
+        return {
+            navigationOpen: false,
+            rootNode: null
+        }
+    },
+    methods: {
+        openNode(id, classname) {
+            STUDIP.eventBus.emit('load-tree-node', classname + '_' + id);
+            this.$refs[id][0].focus();
+        },
+        actionMenuItems() {
+            let entries = [];
+
+            if (this.editable && this.createUrl !== '') {
+                entries.push({
+                    id: 'create',
+                    label: this.$gettext('Neues Unterelement anlegen'),
+                    icon: 'add',
+                    emit: 'add-tree-node',
+                    emitArguments: this.node
+                });
+            }
+
+            if (this.editable && this.node.attributes.id !== 'root') {
+                entries.push({
+                    id: 'edit',
+                    label: this.$gettext('Dieses Element bearbeiten'),
+                    icon: 'edit',
+                    emit: 'edit-tree-node',
+                    emitArguments: this.node
+                });
+                entries.push({
+                    id: 'delete',
+                    label: this.$gettext('Dieses Element löschen'),
+                    icon: 'trash',
+                    emit: 'delete-tree-node',
+                    emitArguments: this.node
+                });
+            }
+
+            return entries;
+        },
+        toggleNavigation() {
+            this.navigationOpen = !this.navigationOpen;
+        },
+        addNode(parent) {
+            STUDIP.Dialog.fromURL(this.createUrl + '/' + parent.id, { data: { from: this.nodeUrl(parent.id) }});
+        },
+        editNode(node) {
+            STUDIP.Dialog.fromURL(this.editUrl + '/' + node.id, { data: { from: this.nodeUrl(node.id) }});
+        },
+        deleteNode(node) {
+            let text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll?');
+            let context = {
+                node: node.attributes.name
+            };
+
+            if (this.numChildren > 0 && this.numCourses === 0) {
+                text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll? Er hat %{ children } Unterelemente.');
+                context.children = this.numChildren;
+            } else if (this.numChildren === 0 && this.numCourses > 0) {
+                text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll? Er hat %{ courses } Veranstaltungszuordnungen.');
+                context.courses = this.numCourses;
+            } else if (this.numChildren > 0 && this.numCourses > 0) {
+                text = this.$gettext('Sind sie sicher, dass der Eintrag "%{ node }" gelöscht werden soll? Er hat %{ children } Unterelemente und %{ courses } Veranstaltungszuordnungen.');
+                context.children = this.numChildren;
+                context.courses = this.numCourses;
+            }
+
+            STUDIP.Dialog.confirm(
+                this.$gettextInterpolate(text, context)
+            ).done(() => {
+                axios.post(this.deleteUrl + '/' + node.id).then(() => {
+                    const parent = node.attributes.ancestors[node.attributes.ancestors.length - 2];
+                    window.location = this.nodeUrl(parent.classname + '_' + parent.id);
+                });
+            });
+        }
+    },
+    mounted() {
+        const root = this.node.attributes.ancestors[0];
+        this.getNode(root.classname + '_' + root.id).then(response => {
+            this.rootNode = response.data.data;
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeCourseDetails.vue b/resources/vue/components/tree/TreeCourseDetails.vue
new file mode 100644
index 00000000000..30f101b2413
--- /dev/null
+++ b/resources/vue/components/tree/TreeCourseDetails.vue
@@ -0,0 +1,51 @@
+<template>
+    <div v-if="details" class="course-details">
+        <div class="semester">
+            ({{ details.semester }})
+        </div>
+        <div class="admission-state" v-if="details.admissionstate">
+            <studip-icon :shape="details.admissionstate.icon" :role="details.admissionstate.role"
+                         :title="details.admissionstate.info"></studip-icon>
+        </div>
+        <ul class="course-lecturers">
+            <li v-for="(lecturer, index) in details.lecturers" :key="index">
+                <a :href="profileUrl(lecturer.username)"
+                   :title="$gettextInterpolate($gettext('Zum Profil von %{ user }'),
+                        { user: lecturer.name })">
+                    {{ lecturer.name }}
+                </a>
+            </li>
+        </ul>
+        <MountingPortal :mountTo="'#course-dates-' + course" :append="true">
+            <span v-html="details.dates"></span>
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import axios from 'axios';
+import { TreeMixin } from '../../mixins/TreeMixin';
+
+export default {
+    name: 'TreeCourseDetails',
+    mixins: [ TreeMixin ],
+    props: {
+        course: {
+            type: String,
+            required: true
+        }
+    },
+    data() {
+        return {
+            details: null
+        }
+    },
+    mounted() {
+        axios.get(
+            STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/course/details/' + this.course)
+        ).then(response => {
+            this.details = response.data;
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeExportWidget.vue b/resources/vue/components/tree/TreeExportWidget.vue
new file mode 100644
index 00000000000..62b73b0378c
--- /dev/null
+++ b/resources/vue/components/tree/TreeExportWidget.vue
@@ -0,0 +1,50 @@
+<template>
+    <sidebar-widget v-if="exportData.length > 0" id="export-widget" class="sidebar-export" :title="$gettext('Export')">
+        <template #content>
+            <form class="sidebar-export">
+                <studip-icon shape="export" :size="16"></studip-icon>
+                <a :href="url" :title="title" @click.prevent="createExport()">{{ title }}</a>
+            </form>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import axios from 'axios';
+import SidebarWidget from '../SidebarWidget.vue';
+import StudipIcon from '../StudipIcon.vue';
+
+export default {
+    name: 'TreeExportWidget',
+    components: {
+        SidebarWidget, StudipIcon
+    },
+    props: {
+        url: {
+            type: String,
+            required: true
+        },
+        title: {
+            type: String,
+            required: true
+        },
+        exportData: {
+            type: Array,
+            default: () => []
+        }
+    },
+    methods: {
+        createExport() {
+            const fd = new FormData();
+            fd.append('courses', this.exportData.map(entry => entry.id));
+            axios.post(
+                this.url,
+                fd,
+                { headers: { 'Content-Type': 'multipart/form-data' }}
+            ).then(response => {
+                window.open(response.data);
+            });
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeNodeCourseInfo.vue b/resources/vue/components/tree/TreeNodeCourseInfo.vue
new file mode 100644
index 00000000000..db31d148c3f
--- /dev/null
+++ b/resources/vue/components/tree/TreeNodeCourseInfo.vue
@@ -0,0 +1,76 @@
+<template>
+    <div class="studip-tree-child-description">
+        <template v-if="showingAllCourses">
+            <div v-translate="{ count: courseCount }" :translate-n="courseCount"
+                  translate-plural="<strong>%{count}</strong> Veranstaltungen auf dieser Ebene.">
+                <strong>Eine</strong> Veranstaltung auf dieser Ebene.
+            </div>
+        </template>
+        <div v-else v-translate="{ count: courseCount }" :translate-n="courseCount"
+              translate-plural="<strong>%{count}</strong> Veranstaltungen auf dieser Ebene.">
+            <strong>Eine</strong> Veranstaltung auf dieser Ebene.
+        </div>
+        <template v-if="!showingAllCourses">
+            <div v-translate="{ count: allCourseCount }" :translate-n="allCourseCount"
+                  translate-plural="<strong>%{count}</strong> Veranstaltungen auf allen Unterebenen.">
+                <strong>Eine</strong> Veranstaltung auf allen Unterebenen.
+            </div>
+        </template>
+        <div v-else v-translate="{ count: allCourseCount }" :translate-n="allCourseCount"
+              translate-plural="<strong>%{count}</strong> Veranstaltungen auf allen Unterebenen.">
+            <strong>Eine</strong> Veranstaltung auf allen Unterebenen.
+        </div>
+    </div>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+
+export default {
+    name: 'TreeNodeCourseInfo',
+    mixins: [ TreeMixin ],
+    props: {
+        node: {
+            type: Object,
+            required: true
+        },
+        semester: {
+            type: String,
+            default: 'all'
+        },
+        semClass: {
+            type: Number,
+            default: 0
+        }
+    },
+    data() {
+        return {
+            courseCount: 0,
+            allCourseCount: 0,
+            showingAllCourses: false
+        }
+    },
+    methods: {
+        showAllCourses(state) {
+            this.showingAllCourses = state;
+            this.$emit('showAllCourses', state);
+        }
+    },
+    mounted() {
+        this.getNodeCourseInfo(this.node, this.semester, this.semClass)
+            .then(info => {
+                this.courseCount = info?.data.courses;
+                this.allCourseCount = info?.data.allCourses;
+            });
+    },
+    watch: {
+        node(newNode) {
+            this.getNodeCourseInfo(newNode, this.semester, this.semClass)
+                .then(info => {
+                    this.courseCount = info?.data.courses;
+                    this.allCourseCount = info?.data.allCourses;
+                });
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeNodeCoursePath.vue b/resources/vue/components/tree/TreeNodeCoursePath.vue
new file mode 100644
index 00000000000..26ab88eb4d4
--- /dev/null
+++ b/resources/vue/components/tree/TreeNodeCoursePath.vue
@@ -0,0 +1,54 @@
+<template>
+    <div>
+        <studip-icon shape="info-circle" @click="togglePathInfo"></studip-icon>
+        <ul v-if="showPaths" class="studip-tree-course-path">
+            <li v-for="(path, pindex) in paths" :key="pindex">
+                <button @click.prevent="openNode(path[path.length - 1].id)">
+                    <template v-for="(segment) in path">
+                        / {{ segment.name }}
+                    </template>
+                </button>
+            </li>
+        </ul>
+    </div>
+</template>
+<script>
+import axios from 'axios';
+import StudipIcon from '../StudipIcon.vue';
+
+export default {
+    name: 'TreeNodeCoursePath',
+    components: { StudipIcon },
+    props: {
+        courseId: {
+            type: String,
+            required: true
+        },
+        nodeClass: {
+            type: String,
+            required: true
+        }
+    },
+    data() {
+        return {
+            paths: [],
+            showPaths: false
+        }
+    },
+    methods: {
+        openNode(id) {
+            STUDIP.eventBus.emit('load-tree-node', this.nodeClass + '_' + id);
+        },
+        togglePathInfo() {
+            this.showPaths = !this.showPaths;
+        }
+    },
+    mounted() {
+        axios.get(
+            STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/course/pathinfo/' + this.nodeClass + '/' + this.courseId)
+        ).then(response => {
+            this.paths = response.data;
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeNodeTile.vue b/resources/vue/components/tree/TreeNodeTile.vue
new file mode 100644
index 00000000000..cbb0679eaef
--- /dev/null
+++ b/resources/vue/components/tree/TreeNodeTile.vue
@@ -0,0 +1,46 @@
+<template>
+    <a :href="url" @click.prevent="openNode" :title="$gettextInterpolate($gettext('Unterebene %{ node } öffnen'),
+                                { node: node.attributes.name })">
+        <p class="studip-tree-child-title">
+            {{ node.attributes.name }}
+        </p>
+
+        <tree-node-course-info :node="node" :semester="semester"
+                               :sem-class="semClass"></tree-node-course-info>
+    </a>
+</template>
+
+<script>
+import TreeNodeCourseInfo from './TreeNodeCourseInfo.vue';
+export default {
+    name: 'TreeNodeTile',
+    components: { TreeNodeCourseInfo },
+    props: {
+        node: {
+            type: Object,
+            required: true
+        },
+        url: {
+            type: String,
+            required: true
+        },
+        withChildren: {
+            type: Boolean,
+            default: true
+        },
+        semester: {
+            type: String,
+            default: 'all'
+        },
+        semClass: {
+            type: Number,
+            default: 0
+        }
+    },
+    methods: {
+        openNode() {
+            STUDIP.eventBus.emit('open-tree-node', this.node);
+        }
+    }
+}
+</script>
diff --git a/resources/vue/components/tree/TreeSearchResult.vue b/resources/vue/components/tree/TreeSearchResult.vue
new file mode 100644
index 00000000000..19bc6b927a4
--- /dev/null
+++ b/resources/vue/components/tree/TreeSearchResult.vue
@@ -0,0 +1,85 @@
+<template>
+    <div v-if="isLoading">
+        <studip-progress-indicator></studip-progress-indicator>
+    </div>
+    <article v-else class="studip-tree-table">
+        <table v-if="courses.length > 0" class="default studip-tree-table">
+            <caption>
+                <studip-icon shape="search" :size="20"></studip-icon>
+                {{ $gettextInterpolate($ngettext('Ein Eintrag für den Begriff "%{searchterm}" gefunden',
+                    '%{count} Einträge für den Begriff "%{searchterm}" gefunden', courses.length),
+                    { count: courses.length, searchterm: searchConfig.searchterm}) }}
+            </caption>
+            <colgroup>
+                <col style="width: 30px">
+                <col>
+                <col>
+            </colgroup>
+            <thead>
+            <tr>
+                <th></th>
+                <th>{{ $gettext('Name') }}</th>
+                <th>{{ $gettext('Information') }}</th>
+            </tr>
+            </thead>
+            <tbody>
+                <tr v-for="(course) in courses" :key="course.id" class="studip-tree-child studip-tree-course">
+                    <td>
+                        <studip-icon shape="seminar" :size="26"></studip-icon>
+                    </td>
+                    <td>
+                        <a :href="courseUrl(course.id)"
+                           :title="$gettextInterpolate($gettext('Zur Veranstaltung %{name}'), {name:  + course.attributes.title})">
+                            <template v-if="course.attributes['course-number']">
+                                {{ course.attributes['course-number'] }}
+                            </template>
+                            {{ course.attributes.title }}
+                            <div :id="'course-dates-' + course.id" class="course-dates"></div>
+                        </a>
+                        <tree-node-course-path :node-class="searchConfig.classname"
+                                               :course-id="course.id"></tree-node-course-path>
+                    </td>
+                    <td>
+                        <tree-course-details :course="course.id"></tree-course-details>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+    </article>
+</template>
+
+<script>
+import { TreeMixin } from '../../mixins/TreeMixin';
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import StudipIcon from '../StudipIcon.vue';
+import TreeNodeCoursePath from './TreeNodeCoursePath.vue';
+import TreeCourseDetails from './TreeCourseDetails.vue';
+
+export default {
+    name: 'TreeSearchResult',
+    components: { StudipIcon, StudipProgressIndicator, TreeNodeCoursePath, TreeCourseDetails },
+    mixins: [ TreeMixin ],
+    props: {
+        searchConfig: {
+            type: Object,
+            required: true
+        }
+    },
+    data() {
+        return {
+            node: null,
+            isLoading: false,
+            isLoaded: false,
+            courses: []
+        }
+    },
+    mounted() {
+        this.getNode(this.searchConfig.classname + '_root').then(response => {
+            this.getNodeCourses(response.data.data, this.searchConfig.semester,0, this.searchConfig.searchterm, true)
+                .then(courses => {
+                    this.courses = courses.data.data;
+                });
+        });
+    }
+}
+</script>
diff --git a/resources/vue/mixins/TreeMixin.js b/resources/vue/mixins/TreeMixin.js
new file mode 100644
index 00000000000..9a0292ecf33
--- /dev/null
+++ b/resources/vue/mixins/TreeMixin.js
@@ -0,0 +1,108 @@
+import axios from 'axios';
+
+export const TreeMixin = {
+    data() {
+        return {
+            showProgressIndicatorTimeout: 500
+        };
+    },
+    methods: {
+        async getNode(id) {
+            return axios.get(STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + id));
+        },
+        async getNodeChildren(node, visibleOnly = true) {
+            let parameters = {};
+
+            if (visibleOnly) {
+                parameters['filter[visible]'] = true;
+            }
+
+            return axios.get(
+                STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + node.id + '/children'),
+                { params: parameters }
+            );
+        },
+        async getNodeCourses(node, semesterId = 'all', semClass = 0, searchterm = '', recursive = false, ids = []) {
+            let parameters = {};
+
+            if (semesterId !== 'all' && semesterId !== '0') {
+                parameters['filter[semester]'] = semesterId;
+            }
+
+            if (searchterm !== '') {
+                parameters['filter[q]'] = searchterm;
+            }
+
+            if (semClass !== 0) {
+                parameters['filter[semclass]'] = semClass;
+            }
+
+            if (recursive) {
+                parameters['filter[recursive]'] = true;
+            }
+
+            if (ids.length > 0) {
+                parameters['filter[ids]'] = ids;
+            }
+
+            return axios.get(
+                STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + node.id + '/courses'),
+                {params: parameters}
+            );
+        },
+        async getNodeCourseInfo(node, semesterId, semClass = 0) {
+            let parameters = {};
+
+            if (semesterId !== 'all' && semesterId !== '0') {
+                parameters['filter[semester]'] = semesterId;
+            }
+
+            if (semClass !== 0) {
+                parameters['filter[semclass]'] = semClass;
+            }
+
+            return axios.get(
+                STUDIP.URLHelper.getURL('jsonapi.php/v1/tree-node/' + node.id + '/courseinfo'),
+                { params: parameters }
+            );
+        },
+        nodeUrl(node_id, semester = null ) {
+            return STUDIP.URLHelper.getURL('', { node_id, semester })
+        },
+        courseUrl(courseId) {
+            return STUDIP.URLHelper.getURL('dispatch.php/course/details', { cid: courseId })
+        },
+        profileUrl(username) {
+            return STUDIP.URLHelper.getURL('dispatch.php/profile', { username })
+        },
+        exportUrl() {
+            return STUDIP.URLHelper.getURL('dispatch.php/tree/export_csv');
+        },
+        editNode(editUrl, id) {
+            STUDIP.Dialog.fromURL(
+                editUrl + '/' + id,
+                {
+                    size: 'medium'
+                }
+            );
+        },
+        updateSorting(parentId, children) {
+            let data = {};
+
+            let position = 0;
+            for (const child of children) {
+                data[child.attributes.id] = position;
+                position++;
+            }
+
+            const fd = new FormData();
+            fd.append('sorting', JSON.stringify(data));
+            axios.post(
+                STUDIP.URLHelper.getURL('dispatch.php/admin/tree/sort/' + parentId),
+                fd,
+                { headers: { 'Content-Type': 'multipart/form-data' }}
+            );
+            STUDIP.Vue.emit('sort-tree-children', { parent: parentId, children: children });
+        }
+    }
+}
-- 
GitLab