From 5da08f6c40f714fd37860f3ac19662888aa3780b Mon Sep 17 00:00:00 2001
From: Ron Lucke <lucke@elan-ev.de>
Date: Thu, 22 Jun 2023 10:36:07 +0000
Subject: [PATCH] =?UTF-8?q?Courseware:=20=C3=9Cbersichtsseite=20=C3=BCber?=
 =?UTF-8?q?=20Feedback=20und=20Kommentare?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes #2689

Merge request studip/studip!1754
---
 app/controllers/course/courseware.php         | 192 ++++++++++++
 .../course/courseware/comments_overview.php   |   5 +
 lib/modules/CoursewareModule.class.php        |   4 +
 .../javascripts/bootstrap/courseware.js       |  11 +
 .../assets/stylesheets/scss/courseware.scss   |  34 ++-
 .../vue/components/courseware/CommentsApp.vue |  46 +++
 .../courseware/CoursewareBlockComments.vue    |   7 +
 .../CoursewareBlockCommentsOverview.vue       | 282 ++++++++++++++++++
 .../courseware/CoursewareBlockFeedback.vue    |   7 +
 .../CoursewareCommentsOverviewDialog.vue      |  98 ++++++
 ...areCommentsOverviewWidgetFilterCreated.vue |  51 ++++
 ...sewareCommentsOverviewWidgetFilterType.vue |  51 ++++
 ...sewareCommentsOverviewWidgetFilterUnit.vue |  60 ++++
 .../CoursewareStructuralElementComments.vue   |   7 +
 ...ewareStructuralElementCommentsOverview.vue | 267 +++++++++++++++++
 .../CoursewareStructuralElementFeedback.vue   |   7 +
 resources/vue/courseware-comments-app.js      | 114 +++++++
 .../courseware/comments-overview-helper.js    |  16 +
 .../courseware/courseware-comments.module.js  | 146 +++++++++
 19 files changed, 1395 insertions(+), 10 deletions(-)
 create mode 100644 app/views/course/courseware/comments_overview.php
 create mode 100644 resources/vue/components/courseware/CommentsApp.vue
 create mode 100644 resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue
 create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue
 create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue
 create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue
 create mode 100644 resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue
 create mode 100644 resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue
 create mode 100644 resources/vue/courseware-comments-app.js
 create mode 100644 resources/vue/mixins/courseware/comments-overview-helper.js
 create mode 100644 resources/vue/store/courseware/courseware-comments.module.js

diff --git a/app/controllers/course/courseware.php b/app/controllers/course/courseware.php
index f35483f311f..29c48d9e032 100644
--- a/app/controllers/course/courseware.php
+++ b/app/controllers/course/courseware.php
@@ -91,6 +91,190 @@ class Course_CoursewareController extends CoursewareController
         $this->render_pdf($element->pdfExport($user, $with_children), trim($element->title).'.pdf');
     }
 
+    public function comments_overview_action(): void
+    {
+        Navigation::activateItem('course/courseware/comments');
+        $this->setCommentsOverviewSidebar();
+    }
+
+    public function comments_overview_data_action()
+    {
+        $user = User::findCurrent();
+        $cid = Request::get('cid');
+        $units = [];
+        $elements = [];
+        $containers = [];
+        $blocks = [];
+        $block_comments = [];
+        $block_feedbacks = [];
+        $element_comments = [];
+        $element_feedbacks = [];
+
+        $statement = DBManager::get()->prepare("
+            SELECT elem.id AS elem_id, container.id AS container_id, block.id AS block_id, comment.id AS comment_id
+            FROM `cw_block_comments` AS comment
+            INNER JOIN `cw_blocks` AS block ON (block.id = comment.block_id)
+            INNER JOIN `cw_containers` AS container ON (container.id = block.container_id)
+            INNER JOIN `cw_structural_elements` AS elem ON (elem.id = container.structural_element_id)
+            WHERE elem.range_type = 'course'
+            AND elem.range_id = :range_id
+        ");
+
+        $statement->execute(['range_id' => $cid]);
+        $cw_block_comments = $statement->fetchAll();
+
+        foreach ($cw_block_comments as $row) {
+            $element = \Courseware\StructuralElement::find($row['elem_id']);
+            if (!$element->canRead($user)) {
+                continue;
+            }
+            if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
+                $elements[] = $element;
+                $unit = $element->findUnit();
+                $unitElement = $unit->structural_element;
+                if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
+                    $elements[] = $unitElement;
+                }
+                if (!$this->arrayHasDataForId($units, $unit->id)) {
+                    $units[] = $unit;
+                }
+            }
+            if (!$this->arrayHasDataForId($containers, $row['container_id'])) {
+                $containers[] = \Courseware\Container::find($row['container_id']);
+            }
+            if (!$this->arrayHasDataForId($blocks, $row['block_id'])) {
+                $blocks[] = \Courseware\Block::find($row['block_id']);
+            }
+            if (!$this->arrayHasDataForId($block_comments, $row['comment_id'])) {
+                $block_comments[] = \Courseware\BlockComment::find($row['comment_id']);
+            }
+        }
+
+        $statement = DBManager::get()->prepare("
+            SELECT elem.id AS elem_id, container.id AS container_id, block.id AS block_id, feedback.id AS feedback_id
+            FROM `cw_block_feedbacks` AS feedback
+            INNER JOIN `cw_blocks` AS block ON (block.id = feedback.block_id)
+            INNER JOIN `cw_containers` AS container ON (container.id = block.container_id)
+            INNER JOIN `cw_structural_elements` AS elem ON (elem.id = container.structural_element_id)
+            WHERE elem.range_type = 'course'
+            AND elem.range_id = :range_id
+        ");
+
+        $statement->execute(['range_id' => $cid]);
+        $cw_block_feedbacks = $statement->fetchAll();
+
+        foreach ($cw_block_feedbacks as $row) {
+            $element = \Courseware\StructuralElement::find($row['elem_id']);
+            if (!$element->canEdit($user)) {
+                continue;
+            }
+            if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
+                $elements[] = $element;
+                $unit = $element->findUnit();
+                $unitElement = $unit->structural_element;
+                if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
+                    $elements[] = $unitElement;
+                }
+                if (!$this->arrayHasDataForId($units, $unit->id)) {
+                    $units[] = $unit;
+                }
+            }
+            if (!$this->arrayHasDataForId($containers, $row['container_id'])) {
+                $containers[] = \Courseware\Container::find($row['container_id']);
+            }
+            if (!$this->arrayHasDataForId($blocks, $row['block_id'])) {
+                $blocks[] = \Courseware\Block::find($row['block_id']);
+            }
+            if (!$this->arrayHasDataForId($block_feedbacks, $row['feedback_id'])) {
+                $block_feedbacks[] = \Courseware\BlockFeedback::find($row['feedback_id']);
+            }
+        }
+
+        $statement = DBManager::get()->prepare("
+            SELECT elem.id AS elem_id, comment.id AS comment_id
+            FROM `cw_structural_element_comments` AS comment
+            INNER JOIN `cw_structural_elements` AS elem ON (elem.id = comment.structural_element_id)
+            WHERE elem.range_type = 'course'
+            AND elem.range_id = :range_id
+        ");
+
+        $statement->execute(['range_id' => $cid]);
+        $cw_structural_element_comments = $statement->fetchAll();
+
+        foreach ($cw_structural_element_comments as $row) {
+            $element = \Courseware\StructuralElement::find($row['elem_id']);
+            if (!$element->canRead($user)) {
+                continue;
+            }
+            if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
+                $elements[] = $element;
+                $unit = $element->findUnit();
+                $unitElement = $unit->structural_element;
+                if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
+                    $elements[] = $unitElement;
+                }
+                if (!$this->arrayHasDataForId($units, $unit->id)) {
+                    $units[] = $unit;
+                }
+            }
+            if (!$this->arrayHasDataForId($element_comments, $row['comment_id'])) {
+                $element_comments[] = \Courseware\StructuralElementComment::find($row['comment_id']);
+            }
+        }
+
+        $statement = DBManager::get()->prepare("
+            SELECT elem.id AS elem_id, feedback.id AS feedback_id
+            FROM `cw_structural_element_feedbacks` AS feedback
+            INNER JOIN `cw_structural_elements` AS elem ON (elem.id = feedback.structural_element_id)
+            WHERE elem.range_type = 'course'
+            AND elem.range_id = :range_id
+        ");
+
+        $statement->execute(['range_id' => $cid]);
+        $cw_structural_element_feedbacks = $statement->fetchAll();
+
+        foreach ($cw_structural_element_feedbacks as $row) {
+            $element = \Courseware\StructuralElement::find($row['elem_id']);
+            if (!$element->canEdit($user)) {
+                continue;
+            }
+            if (!$this->arrayHasDataForId($elements, $row['elem_id'])) {
+                $elements[] = $element;
+                $unit = $element->findUnit();
+                $unitElement = $unit->structural_element;
+                if (!$this->arrayHasDataForId($elements, $unitElement->id)) {
+                    $elements[] = $unitElement;
+                }
+                if (!$this->arrayHasDataForId($units, $unit->id)) {
+                    $units[] = $unit;
+                }
+            }
+            if (!$this->arrayHasDataForId($element_feedbacks, $row['feedback_id'])) {
+                $element_feedbacks[] = \Courseware\StructuralElementFeedback::find($row['feedback_id']);
+            }
+        }
+
+        $encoder = app(\Neomerx\JsonApi\Contracts\Encoder\EncoderInterface::class);
+
+        $data = [
+            'units' => $encoder->encodeData($units),
+            'elements' => $encoder->encodeData($elements),
+            'containers' => $encoder->encodeData($containers),
+            'blocks' => $encoder->encodeData($blocks),
+            'block_comments' => $encoder->encodeData($block_comments),
+            'block_feedbacks' => $encoder->encodeData($block_feedbacks),
+            'element_comments' => $encoder->encodeData($element_comments),
+            'element_feedbacks' => $encoder->encodeData($element_feedbacks),
+        ];
+        $this->render_json($data);
+    }
+
+    private function arrayHasDataForId(array $array, $id): bool
+    {
+        $ids = array_column($array, null, 'id');
+        return !empty($ids[$id]);
+    }
+
     private function setIndexSidebar(): void
     {
         $sidebar = Sidebar::Get();
@@ -112,4 +296,12 @@ class Course_CoursewareController extends CoursewareController
         $sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-type'));
         $sidebar->addWidget(new VueWidget('courseware-activities-widget-filter-unit'));
     }
+
+    private function setCommentsOverviewSidebar(): void
+    {
+        $sidebar = Sidebar::Get();
+        $sidebar->addWidget(new VueWidget('courseware-comments-overview-widget-filter-type'));
+        $sidebar->addWidget(new VueWidget('courseware-comments-overview-widget-filter-created'));
+        $sidebar->addWidget(new VueWidget('courseware-comments-overview-widget-filter-unit'));
+    }
 }
diff --git a/app/views/course/courseware/comments_overview.php b/app/views/course/courseware/comments_overview.php
new file mode 100644
index 00000000000..358a5e579f3
--- /dev/null
+++ b/app/views/course/courseware/comments_overview.php
@@ -0,0 +1,5 @@
+<div
+    id="courseware-comments-app"
+    entry-type="courses"
+    entry-id="<?= htmlReady(Context::getId()) ?>"
+></div>
\ No newline at end of file
diff --git a/lib/modules/CoursewareModule.class.php b/lib/modules/CoursewareModule.class.php
index 56a205c2f05..d085de22eb6 100644
--- a/lib/modules/CoursewareModule.class.php
+++ b/lib/modules/CoursewareModule.class.php
@@ -67,6 +67,10 @@ class CoursewareModule extends CorePlugin implements SystemPlugin, StudipModule
             'tasks',
             new Navigation(_('Aufgaben'), 'dispatch.php/course/courseware/tasks?cid=' . $courseId)
         );
+        $navigation->addSubNavigation(
+            'comments',
+            new Navigation(_('Kommentare und Feedback'), 'dispatch.php/course/courseware/comments_overview?cid=' . $courseId)
+        );
 
         return ['courseware' => $navigation];
     }
diff --git a/resources/assets/javascripts/bootstrap/courseware.js b/resources/assets/javascripts/bootstrap/courseware.js
index ccb789217d8..d24bd4db392 100644
--- a/resources/assets/javascripts/bootstrap/courseware.js
+++ b/resources/assets/javascripts/bootstrap/courseware.js
@@ -86,4 +86,15 @@ STUDIP.domReady(() => {
             });
         });
     }
+
+    if (document.getElementById('courseware-comments-app')) {
+        STUDIP.Vue.load().then(({ createApp }) => {
+            import(
+                /* webpackChunkName: "courseware-comments-app" */
+                '@/vue/courseware-comments-app.js'
+            ).then(({ default: mountApp }) => {
+                return mountApp(STUDIP, createApp, '#courseware-comments-app');
+            });
+        });
+    }
 });
diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss
index 09a3a7e5e2b..964d1bf5734 100644
--- a/resources/assets/stylesheets/scss/courseware.scss
+++ b/resources/assets/stylesheets/scss/courseware.scss
@@ -2254,15 +2254,21 @@ c o m m e n t s  &  f e e d b a c k
     scroll-behavior: smooth;
 }
 
+.studip-dialog-content {
+    .cw-structural-element-feedback-items,
+    .cw-structural-element-comments-items,
+    .cw-block-feedback-items,
+    .cw-block-comments-items {
+        max-height: unset;
+    }
+}
+
 .cw-talk-bubble {
     margin: 10px 20px;
     position: relative;
     width: 85%;
     height: auto;
-    background-color: $dark-gray-color-10;
-    border-radius: 5px;
-    -webkit-border-radius: 5px;
-    -moz-border-radius: 5px;
+    background-color: var(--dark-gray-color-10);
     float: left;
 
     .cw-talk-bubble-talktext {
@@ -2271,7 +2277,7 @@ c o m m e n t s  &  f e e d b a c k
         line-height: 1.5em;
 
         .cw-talk-bubble-talktext-time {
-            color: $dark-gray-color-45;
+            color: var(--dark-gray-color-80);
             text-align: right;
             font-size: 0.8em;
             margin-bottom: -0.5em;
@@ -2290,10 +2296,7 @@ c o m m e n t s  &  f e e d b a c k
         top: 0px;
         bottom: auto;
         border: 22px solid;
-        border-color: $dark-gray-color-10 transparent transparent transparent;
-        border-radius: 5px;
-        -webkit-border-radius: 5px;
-        -moz-border-radius: 5px;
+        border-color: var(--dark-gray-color-10) transparent transparent transparent;
         left: -20px;
         right: auto;
     }
@@ -2312,7 +2315,7 @@ c o m m e n t s  &  f e e d b a c k
 
         span {
             padding-left: 4px;
-            color: $dark-gray-color-45;
+            color: var(--dark-gray-color-45);
             font-weight: 600;
             vertical-align: top;
         }
@@ -5697,3 +5700,14 @@ r a d i o s e t
 /* * * * * * * * * * * *
 r a d i o s e t  e n d 
 * * * * * * * * * * * */
+/* * * * * * * * * * * * * * * *
+c o m m e n t s  o v e r v i e w
+* * * * * * * * * * * * * * * */
+
+.cw-comments-overview-dialog-comments-context {
+    margin: 0 0 1.5em 0;
+}
+
+/* * * * * * * * * * * * * * * * * * * *
+c o m m e n t s  o v e r v i e w  e n d
+* * * * * * * * * * * * * * * * * * * */
diff --git a/resources/vue/components/courseware/CommentsApp.vue b/resources/vue/components/courseware/CommentsApp.vue
new file mode 100644
index 00000000000..1716d4ddf71
--- /dev/null
+++ b/resources/vue/components/courseware/CommentsApp.vue
@@ -0,0 +1,46 @@
+<template>
+    <div class="cw-comments-overview-wrapper">
+        <courseware-block-comments-overview v-if="showBlocks"/>
+        <courseware-structural-element-comments-overview v-if="showElements"/>
+        <MountingPortal mountTo="#courseware-comments-overview-widget-filter-type" name="sidebar-views">
+            <courseware-comments-overview-widget-filter-type />
+        </MountingPortal>
+        <MountingPortal mountTo="#courseware-comments-overview-widget-filter-created" name="sidebar-views">
+            <courseware-comments-overview-widget-filter-created />
+        </MountingPortal>
+        <MountingPortal mountTo="#courseware-comments-overview-widget-filter-unit" name="sidebar-views">
+            <courseware-comments-overview-widget-filter-unit />
+        </MountingPortal>
+    </div>
+</template>
+
+<script>
+import CoursewareBlockCommentsOverview from './CoursewareBlockCommentsOverview.vue';
+import CoursewareStructuralElementCommentsOverview from './CoursewareStructuralElementCommentsOverview.vue';
+import CoursewareCommentsOverviewWidgetFilterType from './CoursewareCommentsOverviewWidgetFilterType.vue';
+import CoursewareCommentsOverviewWidgetFilterCreated from './CoursewareCommentsOverviewWidgetFilterCreated.vue';
+import CoursewareCommentsOverviewWidgetFilterUnit from './CoursewareCommentsOverviewWidgetFilterUnit.vue';
+
+import { mapGetters } from 'vuex';
+
+export default {
+    components: {
+        CoursewareBlockCommentsOverview,
+        CoursewareStructuralElementCommentsOverview,
+        CoursewareCommentsOverviewWidgetFilterType,
+        CoursewareCommentsOverviewWidgetFilterCreated,
+        CoursewareCommentsOverviewWidgetFilterUnit,
+    },
+    computed: {
+        ...mapGetters({
+            typeFilter: 'typeFilter',
+        }),
+        showBlocks() {
+            return this.typeFilter === 'blocks' || this.typeFilter === 'all';
+        },
+        showElements() {
+            return this.typeFilter === 'elements' || this.typeFilter === 'all';
+        }
+    }
+}
+</script>
\ No newline at end of file
diff --git a/resources/vue/components/courseware/CoursewareBlockComments.vue b/resources/vue/components/courseware/CoursewareBlockComments.vue
index 8e18909cc3d..6ef33221374 100644
--- a/resources/vue/components/courseware/CoursewareBlockComments.vue
+++ b/resources/vue/components/courseware/CoursewareBlockComments.vue
@@ -1,5 +1,6 @@
 <template>
     <section class="cw-block-comments" :class="[emptyComments ? 'cw-block-comments-empty' : '']">
+        <span class="sr-only" aria-live="polite">{{ srMessage }}</span>
         <div class="cw-block-features-content">
             <div class="cw-block-comments-items" v-show="!emptyComments" ref="commentsRef">
                 <courseware-talk-bubble
@@ -32,6 +33,7 @@ export default {
         return {
             createComment: '',
             placeHolder: this.$gettext('Stellen Sie eine Frage oder kommentieren Sie...'),
+            srMessage: ''
         };
     },
     computed: {
@@ -75,6 +77,7 @@ export default {
             });
         },
         async postComment() {
+            this.updateSrMessage(this.$gettext('Kommentar gesendet'));
             const data = {
                 attributes: {
                     comment: this.createComment
@@ -112,6 +115,10 @@ export default {
 
             return payload;
         },
+        updateSrMessage(message) {
+            this.srMessage = '';
+            this.srMessage = message;
+        }
     },
     mounted() {
         this.loadComments();
diff --git a/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue b/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue
new file mode 100644
index 00000000000..32ba86e6318
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareBlockCommentsOverview.vue
@@ -0,0 +1,282 @@
+<template>
+    <div class="cw-block-comments-overview-wrapper">
+        <table class="default">
+            <caption>
+                {{ $gettext('Blöcke') }}
+            </caption>
+            <colgroup>
+                <col style="width: 16em">
+                <col style="width: 16em">
+                <col style="width: 8em">
+                <col class="responsive-hidden" style="width: 8em">
+                <col class="responsive-hidden" style="width: 8em">
+                <col style="width: 2em">
+            </colgroup>
+            <thead>
+                <tr class="sortable">
+                    <th :class="getSortClass('units')" @click="sort('units')">
+                        <a href="#">{{ $gettext('Lernmaterial') }}</a>
+                    </th>
+                    <th :class="getSortClass('structural-elements')" @click="sort('structural-elements')">
+                        <a href="#">{{ $gettext('Seite') }}</a>
+                    </th>
+                    <th :class="getSortClass('blocks')" @click="sort('blocks')">
+                        <a href="#">{{ $gettext('Blocktyp') }}</a>
+                    </th>
+                    <th class="responsive-hidden" :class="getSortClass('comments')" @click="sort('comments')">
+                        <a href="#">{{ $gettext('Kommentare') }}</a>
+                    </th>
+                    <th class="responsive-hidden" :class="getSortClass('feedback')" @click="sort('feedback')">
+                        <a href="#">{{ $gettext('Feedback') }}</a>
+                    </th>
+                    <th class="actions">
+                        {{ $gettext('Aktionen') }}
+                    </th>
+                </tr>
+            </thead>
+            <tbody v-if="filteredBlocks.length > 0">
+                <tr v-for="block in filteredBlocks" :key="block.id">
+                    <td>{{ block.unitName }}</td>
+                    <td>
+                        <a :href="block.elementURL">
+                            {{ block.element.attributes.title }}
+                        </a>
+                    </td>
+                    <td>{{ block.attributes.title }}</td>
+                    <td class="responsive-hidden">
+                        <a
+                            href="#"
+                            @click.prevent="enableCommentsDialog(block)">
+                            {{ $gettextInterpolate(
+                                $ngettext('%{length} Kommentar', '%{length} Kommentare', block.comments.length),
+                                {length: block.comments.length}
+                            ) }}
+                        </a>
+                    </td>
+                    <td class="responsive-hidden">
+                        <a 
+                            v-if="block.element.attributes['can-edit']"
+                            href="#"
+                            @click.prevent="enableFeedbackDialog(block)"
+                            >
+                            {{ $gettextInterpolate(
+                                $ngettext('%{length} Feedback', '%{length} Feedbacks', block.feedbacks.length),
+                                {length: block.feedbacks.length}
+                            ) }}
+                        </a>
+                        <template v-else>
+                            -
+                        </template>
+                    </td>
+                    <td class="actions">
+                        <studip-action-menu
+                            :items="getMenuItems(block)"
+                            :context="$gettext('Blöcke')"
+                            @showComments="enableCommentsDialog(block)"
+                            @showFeedback="enableFeedbackDialog(block)"
+                        />
+                    </td>
+                </tr>
+            </tbody>
+            <tbody v-else>
+                <tr class="empty">
+                    <td colspan="6">
+                        {{ $gettext('Es wurden keine Kommentare oder Feedback gefunden') }}
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <courseware-comments-overview-dialog
+            v-if="showCommentsDialog"
+            item-type="block"
+            com-type="comment"
+            :item="currentDialogBlock"
+            @close="closeCommentsDialog"
+        />
+        <courseware-comments-overview-dialog
+            v-if="showFeedbackDialog"
+            item-type="block"
+            com-type="feedback"
+            :item="currentDialogBlock"
+            @close="closeFeedbackDialog"
+        />
+    </div>
+</template>
+
+<script>
+import CoursewareCommentsOverviewDialog from './CoursewareCommentsOverviewDialog.vue';
+import commentsOverviewMixin from '@/vue/mixins/courseware/comments-overview-helper.js';
+import { mapGetters } from 'vuex';
+
+export default {
+    name: 'courseware-block-comments-overview',
+    components: {
+        CoursewareCommentsOverviewDialog
+    },
+    mixins: [commentsOverviewMixin],
+    data() {
+        return {
+            blocksWithRelations: [],
+            currentDialogBlock: null,
+            showCommentsDialog: false,
+            showFeedbackDialog: false,
+            sortBy: 'units',
+            sortASC: true,
+        }
+    },
+    computed: {
+        ...mapGetters({
+            units: 'courseware-units/all',
+            elements: 'courseware-structural-elements/all',
+            containers: 'courseware-containers/all',
+            blocks: 'courseware-blocks/all',
+            blockComments: 'courseware-block-comments/all',
+            blockFeedbacks: 'courseware-block-feedback/all',
+            elementComments: 'courseware-structural-element-comments/all',
+            elementFeedbacks: 'courseware-structural-element-feedback/all',
+            containerById: 'courseware-containers/byId',
+            elementById: 'courseware-structural-elements/byId',
+            unitById: 'courseware-units/byId',
+            context: 'context',
+            createdFilter: 'createdFilter',
+            unitFilter: 'unitFilter'
+        }),
+
+        filteredBlocks() {
+            let filteredBlocks = this.blocksWithRelations;
+            if (this.unitFilter !== 'all') {
+                filteredBlocks = filteredBlocks.filter(block => block.unit.id === this.unitFilter);
+            }
+            if (this.createdFilter !== 'all') {
+                filteredBlocks = filteredBlocks.filter(block => block.comments[this.createdFilter] > 0);
+            }
+
+            return this.sortBlocks(filteredBlocks);
+        },
+    },
+    methods: {
+        collectBlockRelations() {
+            this.blocksWithRelations = _.cloneDeep(this.blocks);
+            this.blocksWithRelations.forEach(block => {
+                block.container = this.containerById({ id:block.relationships.container.data.id });
+                block.element = this.elementById({ id: block.container.relationships['structural-element'].data.id });
+                block.unit = this.unitById({ id: block.element.relationships.unit.data.id });
+                const unitRoot = this.elementById({ id: block.unit.relationships['structural-element'].data.id});
+                block.unitName = unitRoot.attributes.title;
+                block.elementURL = STUDIP.URLHelper.getURL(`dispatch.php/course/courseware/courseware/${block.unit.id}?cid=${this.context.id}#/structural_element/${block.element.id}`);
+                block.comments = this.blockComments.filter(comment => comment.relationships.block.data.id === block.id);
+                block.comments.oneDay = 0;
+                block.comments.oneWeek = 0;
+                block.comments.forEach(comment => {
+                    comment.created = this.calcCreated(comment.attributes.mkdate);
+                    if (comment.created.oneDay) {
+                        block.comments.oneDay++;
+                    }
+                    if (comment.created.oneWeek) {
+                        block.comments.oneWeek++;
+                    }
+                });
+                block.feedbacks = this.blockFeedbacks.filter(feedback => feedback.relationships.block.data.id === block.id);
+                block.feedbacks.forEach(feedback => {
+                    feedback.created = this.calcCreated(feedback.attributes.mkdate);
+                });
+            });
+        },
+        enableCommentsDialog(block) {
+            this.currentDialogBlock = block;
+            this.showCommentsDialog = true;
+        },
+        closeCommentsDialog() {
+            this.collectBlockRelations();
+            this.showCommentsDialog = false;
+            this.currentDialogBlock = null;
+        },
+        enableFeedbackDialog(block) {
+            this.currentDialogBlock = block;
+            this.showFeedbackDialog = true;
+        },
+        closeFeedbackDialog() {
+            this.collectBlockRelations();
+            this.showFeedbackDialog = false;
+            this.currentDialogBlock = null;
+        },
+        getMenuItems(block) {
+            let menuItems = [];
+            menuItems.push({ id: 1, label: this.$gettext('Kommentare anzeigen'), icon: 'comment2', emit: 'showComments' });
+            if (block.element.attributes['can-edit']) {
+                menuItems.push({ id: 2, label: this.$gettext('Feedback anzeigen'), icon: 'comment2', emit: 'showFeedback' });
+            }
+
+            return menuItems;
+        },
+        sort(sortBy) {
+            if (this.sortBy === sortBy) {
+                this.sortASC = !this.sortASC;
+            } else {
+                this.sortBy = sortBy;
+            }
+        },
+        getSortClass(col) {
+            if (col === this.sortBy) {
+                return this.sortASC ? 'sortasc' : 'sortdesc';
+            }
+        },
+        sortBlocks(blocks) {
+            switch (this.sortBy) {
+                case 'units':
+                    blocks = blocks.sort((a, b) => {
+                        if (this.sortASC) {
+                            return a.unitName < b.unitName ? -1 : 1;
+                        } else {
+                            return a.unitName > b.unitName ? -1 : 1;
+                        }
+                    });
+                    break;
+                case 'structural-elements':
+                    blocks = blocks.sort((a, b) => {
+                        if (this.sortASC) {
+                            return a.element.attributes.title < b.element.attributes.title ? -1 : 1;
+                        } else {
+                            return a.element.attributes.title > b.element.attributes.title ? -1 : 1;
+                        }
+                    });
+                    break;
+                case 'blocks':
+                    blocks = blocks.sort((a, b) => {
+                        if (this.sortASC) {
+                            return a.attributes.title < b.attributes.title ? -1 : 1;
+                        } else {
+                            return a.attributes.title > b.attributes.title ? -1 : 1;
+                        }
+                    });
+                    break;
+                case 'comments':
+                    blocks = blocks.sort((a, b) => {
+                        if (this.sortASC) {
+                            return a.comments.length - b.comments.length;
+                        } else {
+                            return  b.comments.length - a.comments.length;
+                        }
+                    });
+                    break;
+                case 'feedback':
+                        blocks = blocks.sort((a, b) => {
+                            if (this.sortASC) {
+                                return a.feedbacks.length - b.feedbacks.length;
+                            } else {
+                                return  b.feedbacks.length - a.feedbacks.length;
+                            }
+                        });
+                    break;
+            }
+
+            return blocks;
+        },
+    },
+    mounted() {
+        this.$nextTick(() => {
+            this.collectBlockRelations();
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/courseware/CoursewareBlockFeedback.vue b/resources/vue/components/courseware/CoursewareBlockFeedback.vue
index 88ac2d7747c..a34ad8fed85 100644
--- a/resources/vue/components/courseware/CoursewareBlockFeedback.vue
+++ b/resources/vue/components/courseware/CoursewareBlockFeedback.vue
@@ -4,6 +4,7 @@
         class="cw-block-feedback"
         :class="[emptyFeedback ? 'cw-block-feedback-empty' : '']"
     >
+        <span class="sr-only" aria-live="polite">{{ srMessage }}</span>
         <div class="cw-block-features-content">
             <div class="cw-block-feedback-items" v-show="!emptyFeedback" ref="feedbacks">
                 <courseware-talk-bubble
@@ -45,6 +46,7 @@ export default {
         return {
             feedbackText: '',
             placeHolder: this.$gettext('Schreiben Sie ein Feedback...'),
+            srMessage: ''
         };
     },
     computed: {
@@ -99,6 +101,7 @@ export default {
             });
         },
         async postFeedback() {
+            this.updateSrMessage(this.$gettext('Feedback gesendet'));
             const data = {
                 attributes: {
                     feedback: this.feedbackText,
@@ -115,6 +118,10 @@ export default {
             await this.createFeedback(data, { root: true });
             this.feedbackText = '';
             this.loadFeedback();
+        },
+        updateSrMessage(message) {
+            this.srMessage = '';
+            this.srMessage = message;
         }
     },
     async mounted() {
diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue
new file mode 100644
index 00000000000..3858479e742
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareCommentsOverviewDialog.vue
@@ -0,0 +1,98 @@
+<template>
+    <studip-dialog
+        :title="title"
+        :closeText="$gettext('Schließen')"
+        height="600"
+        width="600"
+        @close="$emit('close')"
+    >
+        <template v-slot:dialogContent>
+            <h2 class="cw-comments-overview-dialog-comments-context">
+                <a :href="contextUrl">{{ context }}</a>
+            </h2>
+            <courseware-block-comments
+                v-if="isBlock && isComment"
+                :block="item" 
+            />
+            <courseware-structural-element-comments
+                v-if="isStructuralElement && isComment"
+                :structuralElement="item"
+            />
+            <courseware-block-feedback
+                v-if="isBlock && isFeedback"
+                :block="item" 
+            />
+            <courseware-structural-element-feedback
+                v-if="isStructuralElement && isFeedback"
+                :structuralElement="item"
+            />
+        </template>
+    </studip-dialog>
+</template>
+
+<script>
+import CoursewareBlockComments from './CoursewareBlockComments.vue';
+import CoursewareStructuralElementComments from './CoursewareStructuralElementComments.vue';
+import CoursewareBlockFeedback from './CoursewareBlockFeedback.vue';
+import CoursewareStructuralElementFeedback from './CoursewareStructuralElementFeedback.vue';
+
+export default {
+    name: 'courseware-comments-overview-dialog',
+    components: {
+        CoursewareBlockComments,
+        CoursewareStructuralElementComments,
+        CoursewareBlockFeedback,
+        CoursewareStructuralElementFeedback
+    },
+    props: {
+        itemType: String,
+        item: Object,
+        comType: String,
+    },
+    computed: {
+        context() {
+            if (this.isBlock) {
+                const block = this.item;
+                return `${block.unitName} | ${block.element.attributes.title} | ${block.attributes.title}`;
+            }
+            if (this.isStructuralElement) {
+                const element = this.item;
+                return `${element.unitName} | ${element.attributes.title}`;
+            }
+            return '';
+        },
+        contextUrl() {
+            if (this.isBlock) {
+                return this.item.elementURL
+            }
+            if (this.isStructuralElement) {
+                return this.item.url;
+            }
+            return '';
+        },
+        title() {
+            if (this.isComment) {
+                return this.$gettext('Kommentare');
+            }
+            if (this.isFeedback) {
+                return this.$gettext('Feedback');
+            }
+
+            return '';
+
+        },
+        isStructuralElement() {
+            return this.itemType === 'structuralElement';
+        },
+        isBlock() {
+            return this.itemType === 'block';
+        },
+        isComment() {
+            return this.comType === 'comment';
+        },
+        isFeedback() {
+            return this.comType === 'feedback';
+        }
+    },
+};
+</script>
diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue
new file mode 100644
index 00000000000..96074cde9a9
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterCreated.vue
@@ -0,0 +1,51 @@
+<template>
+    <sidebar-widget :title="$gettext('Neueste Einträge')">
+        <template #content>
+            <div class="cw-filter-widget">
+                <form class="default" @submit.prevent="">
+                    <select v-model="createdFilter" :aria-label="$gettext('Filter: Neueste Einträge')">
+                        <option value="all">
+                            {{ $gettext('unbeschränkt') }}
+                        </option>
+                        <option value="oneDay">
+                            {{ $gettext('einen Tag alt') }}
+                        </option>
+                        <option value="oneWeek">
+                            {{ $gettext('eine Woche alt') }}
+                        </option>
+                    </select>
+                </form>
+            </div>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+import { mapActions } from 'vuex';
+
+export default {
+    name: 'courseware-comments-overview-widget-filter-created',
+    components: {
+        SidebarWidget
+    },
+    data() {
+        return {
+            createdFilter: 'all',
+        };
+    },
+    methods: {
+        ...mapActions({
+            setCreatedFilter: 'setCreatedFilter',
+        }),
+        filterCreated() {
+            this.setCreatedFilter(this.createdFilter);
+        },
+    },
+    watch: {
+        createdFilter() {
+            this.filterCreated();
+        },
+    }
+};
+</script>
diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue
new file mode 100644
index 00000000000..9aaf61748e1
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterType.vue
@@ -0,0 +1,51 @@
+<template>
+    <sidebar-widget :title="$gettext('Listen')">
+        <template #content>
+            <div class="cw-filter-widget">
+                <form class="default" @submit.prevent="">
+                    <select v-model="typeFilter" :aria-label="$gettext('Filter: Listen')">
+                        <option value="all">
+                            {{ $gettext('Blöcke und Seiten') }}
+                        </option>
+                        <option value="blocks">
+                            {{ $gettext('Blöcke') }}
+                        </option>
+                        <option value="elements">
+                            {{ $gettext('Seiten') }}
+                        </option>
+                    </select>
+                </form>
+            </div>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+import { mapActions } from 'vuex';
+
+export default {
+    name: 'courseware-comments-overview-widget-filter-type',
+    components: {
+        SidebarWidget
+    },
+    data() {
+        return {
+            typeFilter: 'all',
+        };
+    },
+    methods: {
+        ...mapActions({
+            setTypeFilter: 'setTypeFilter',
+        }),
+        filterType() {
+            this.setTypeFilter(this.typeFilter);
+        },
+    },
+    watch: {
+        typeFilter() {
+            this.filterType();
+        },
+    }
+};
+</script>
diff --git a/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue
new file mode 100644
index 00000000000..4d0afd15bf6
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareCommentsOverviewWidgetFilterUnit.vue
@@ -0,0 +1,60 @@
+<template>
+        <sidebar-widget :title="$gettext('Lernmaterial')">
+        <template #content>
+            <div class="cw-filter-widget">
+                <form class="default" @submit.prevent="">
+                    <select v-model="unitFilter" :aria-label="$gettext('Filter: Lernmaterial')">
+                        <option value="all">
+                            {{ $gettext('Alle') }}
+                        </option>
+                        <option v-for="unit in sortedUnits" :key="unit.id" :value="unit.id">
+                            {{ getUnitName(unit) }}
+                        </option>
+                    </select>
+                </form>
+            </div>
+        </template>
+    </sidebar-widget>
+</template>
+
+<script>
+import SidebarWidget from '../SidebarWidget.vue';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+    name: 'courseware-comments-overview-widget-filter-unit',
+    data() {
+        return {
+            unitFilter: 'all',
+        };
+    },
+    computed: {
+        ...mapGetters({
+            units: 'courseware-units/all',
+            elementById: 'courseware-structural-elements/byId',
+        }),
+        sortedUnits() {
+            let units = _.cloneDeep(this.units);
+            units = units.sort((a, b) => this.getUnitName(a) < this.getUnitName(b) ? -1 : 1);
+
+            return units;
+        }
+    },
+    methods: {
+        ...mapActions({
+            setUnitFilter: 'setUnitFilter',
+        }),
+        filterUnit() {
+            this.setUnitFilter(this.unitFilter);
+        },
+        getUnitName(unit) {
+            return this.elementById({ id: unit.relationships['structural-element'].data.id}).attributes.title;
+        }
+    },
+    watch: {
+        unitFilter() {
+            this.filterUnit();
+        },
+    }
+};
+</script>
diff --git a/resources/vue/components/courseware/CoursewareStructuralElementComments.vue b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue
index 197d1bfe066..d1f48b5939b 100644
--- a/resources/vue/components/courseware/CoursewareStructuralElementComments.vue
+++ b/resources/vue/components/courseware/CoursewareStructuralElementComments.vue
@@ -3,6 +3,7 @@
         class="cw-structural-element-comments"
         :class="[emptyComments ? 'cw-structural-element-comments-empty' : '']"
     >
+        <span class="sr-only" aria-live="polite">{{ srMessage }}</span>
         <div class="cw-structural-element-comments-items" v-show="!emptyComments" ref="commentsRef">
             <courseware-talk-bubble
                 v-for="comment in comments"
@@ -33,6 +34,7 @@ export default {
         return {
             createComment: '',
             placeHolder: this.$gettext('Stellen Sie eine Frage oder kommentieren Sie...'),
+            srMessage: ''
         };
     },
     computed: {
@@ -76,6 +78,7 @@ export default {
             });
         },
         async postComment() {
+            this.updateSrMessage(this.$gettext('Kommentar gesendet'));
             const data = {
                 attributes: {
                     comment: this.createComment
@@ -113,6 +116,10 @@ export default {
 
             return payload;
         },
+        updateSrMessage(message) {
+            this.srMessage = '';
+            this.srMessage = message;
+        }
     },
     mounted() {
         this.loadComments();
diff --git a/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue b/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue
new file mode 100644
index 00000000000..a5ba72e7432
--- /dev/null
+++ b/resources/vue/components/courseware/CoursewareStructuralElementCommentsOverview.vue
@@ -0,0 +1,267 @@
+<template>
+    <div class="cw-structural-element-comments-overview-wrapper">
+        <table class="default">
+            <caption>
+                {{ $gettext('Seiten') }}
+            </caption>
+            <colgroup>
+                <col style="width: 16em">
+                <col style="width: 16em">
+                <col style="width: 8em">
+                <col class="responsive-hidden" style="width: 8em">
+                <col class="responsive-hidden" style="width: 8em">
+                <col style="width: 2em">
+            </colgroup>
+            <thead>
+                <tr class="sortable">
+                    <th :class="getSortClass('units')" @click="sort('units')">
+                        <a href="#">{{ $gettext('Lernmaterial') }}</a>
+                    </th>
+                    <th :class="getSortClass('structural-elements')" @click="sort('structural-elements')">
+                        <a href="#">{{ $gettext('Seite') }}</a>
+                    </th>
+                    <th></th>
+                    <th class="responsive-hidden" :class="getSortClass('comments')" @click="sort('comments')">
+                        <a href="#">{{ $gettext('Kommentare') }}</a>
+                    </th>
+                    <th class="responsive-hidden" :class="getSortClass('feedback')" @click="sort('feedback')">
+                        <a href="#">{{ $gettext('Feedback') }}</a>
+                    </th>
+                    <th class="actions">
+                        {{ $gettext('Aktionen') }}
+                    </th>
+                </tr>
+            </thead>
+            <tbody v-if="filteredElements.length > 0">
+                <tr v-for="element in filteredElements" :key="element.id">
+                    <td>{{ element.unitName }}</td>
+                    <td>
+                        <a :href="element.url">
+                            {{ element.attributes.title }}
+                        </a>
+                    </td>
+                    <td></td>
+                    <td class="responsive-hidden">
+                        <a
+                            href="#"
+                            :title="$gettext('Kommentare anzeigen')"
+                            @click.prevent="enableCommentsDialog(element)"
+                        >
+                            {{ $gettextInterpolate(
+                                $ngettext('%{length} Kommentar', '%{length} Kommentare', element.comments.length),
+                                {length: element.comments.length}
+                            ) }}
+                        </a>
+                    </td>
+                    <td class="responsive-hidden">
+                        <a
+                            v-if="element.attributes['can-edit'] && element.feedbacks.length > 0"
+                            href="#"
+                            :title="$gettext('Feedback anzeigen')"
+                            @click.prevent="enableFeedbackDialog(element)"
+                        >
+                            {{ $gettextInterpolate(
+                                $ngettext('%{length} Feedback', '%{length} Feedbacks', element.feedbacks.length),
+                                {length: element.feedbacks.length}
+                            ) }}
+                        </a>
+                        <template v-else>
+                            -
+                        </template>
+                    </td>
+                    <td class="actions">
+                        <studip-action-menu
+                            :items="getMenuItems(element)"
+                            :context="$gettext('Seiten')"
+                            @showComments="enableCommentsDialog(element)"
+                            @showFeedback="enableFeedbackDialog(element)"
+                        />
+                    </td>
+                </tr>
+            </tbody>
+            <tbody v-else>
+                <tr class="empty">
+                    <td colspan="6">
+                        {{ $gettext('Es wurden keine Kommentare oder Feedback gefunden') }}
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <courseware-comments-overview-dialog
+            v-if="showCommentsDialog"
+            item-type="structuralElement"
+            com-type="comment"
+            :item="currentDialogElement"
+            @close="closeCommentsDialog"
+        />
+        <courseware-comments-overview-dialog
+            v-if="showFeedbackDialog"
+            item-type="structuralElement"
+            com-type="feedback"
+            :item="currentDialogElement"
+            @close="closeFeedbackDialog"
+        />
+    </div>
+</template>
+
+<script>
+import CoursewareCommentsOverviewDialog from './CoursewareCommentsOverviewDialog.vue';
+import commentsOverviewMixin from '@/vue/mixins/courseware/comments-overview-helper.js';
+import { mapGetters } from 'vuex';
+
+export default {
+    name: 'courseware-structural-element-comments-overview',
+    components: {
+        CoursewareCommentsOverviewDialog
+    },
+    mixins: [commentsOverviewMixin],
+    data() {
+        return {
+            elementsWithRelations: [],
+            currentDialogElement: null,
+            showCommentsDialog: false,
+            showFeedbackDialog: false,
+            sortBy: 'units',
+            sortASC: true,
+        }
+    },
+    computed: {
+        ...mapGetters({
+            units: 'courseware-units/all',
+            elements: 'courseware-structural-elements/all',
+            elementComments: 'courseware-structural-element-comments/all',
+            elementFeedbacks: 'courseware-structural-element-feedback/all',
+            elementById: 'courseware-structural-elements/byId',
+            unitById: 'courseware-units/byId',
+            context: 'context',
+            createdFilter: 'createdFilter',
+            unitFilter: 'unitFilter'
+        }),
+        filteredElements() {
+            let filteredElements = this.elementsWithRelations;
+            if (this.unitFilter !== 'all') {
+                filteredElements = filteredElements.filter(block => block.unit.id === this.unitFilter);
+            }
+            return this.sortElements(filteredElements);
+        }
+    },
+    methods: {
+        collectElementRelations() {
+            this.elementsWithRelations = _.cloneDeep(this.elements);
+            this.elementsWithRelations.forEach(element => {
+                element.comments = this.elementComments.filter(comment => comment.relationships['structural-element'].data.id === element.id);
+                element.comments.oneDay = 0;
+                element.comments.oneWeek = 0;
+                element.comments.forEach(comment => {
+                    comment.created = this.calcCreated(comment.attributes.mkdate);
+                    if (comment.created.oneDay) {
+                        element.comments.oneDay++;
+                    }
+                    if (comment.created.oneWeek) {
+                        element.comments.oneWeek++;
+                    }
+                });
+                element.feedbacks = this.elementFeedbacks.filter(feedback => feedback.relationships['structural-element'].data.id === element.id);
+                element.feedbacks.forEach(feedback => {
+                    feedback.created = this.calcCreated(feedback.attributes.mkdate);
+                });
+                if (element.comments.length === 0 && element.feedbacks.length === 0) {
+                    element.empty = true;
+                } else {
+                    element.unit = this.unitById({ id: element.relationships.unit.data.id });
+                    const unitRoot = this.elementById({ id: element.unit.relationships['structural-element'].data.id});
+                    element.unitName = unitRoot.attributes.title;
+                    element.url = STUDIP.URLHelper.getURL(`dispatch.php/course/courseware/courseware/${element.unit.id}?cid=${this.context.id}#/structural_element/${element.id}`);
+                }
+            });
+            this.elementsWithRelations = this.elementsWithRelations.filter(element => !element.empty);
+        },
+        enableCommentsDialog(element) {
+            this.currentDialogElement = element;
+            this.showCommentsDialog = true;
+        },
+        closeCommentsDialog() {
+            this.showCommentsDialog = false;
+            this.currentDialogElement = null;
+            this.collectElementRelations();
+        },
+        enableFeedbackDialog(element) {
+            this.currentDialogElement = element;
+            this.showFeedbackDialog = true;
+        },
+        closeFeedbackDialog() {
+            this.showFeedbackDialog = false;
+            this.currentDialogElement = null;
+            this.collectElementRelations();
+        },
+        getMenuItems(element) {
+            let menuItems = [];
+            menuItems.push({ id: 1, label: this.$gettext('Kommentare anzeigen'), icon: 'comment2', emit: 'showComments' });
+            if (element.attributes['can-edit']) {
+                menuItems.push({ id: 2, label: this.$gettext('Feedback anzeigen'), icon: 'comment2', emit: 'showFeedback' });
+            }
+
+            return menuItems;
+        },
+        sort(sortBy) {
+            if (this.sortBy === sortBy) {
+                this.sortASC = !this.sortASC;
+            } else {
+                this.sortBy = sortBy;
+            }
+        },
+        getSortClass(col) {
+            if (col === this.sortBy) {
+                return this.sortASC ? 'sortasc' : 'sortdesc';
+            }
+        },
+        sortElements(elements) {
+            switch (this.sortBy) {
+                case 'units':
+                    elements = elements.sort((a, b) => {
+                        if (this.sortASC) {
+                            return a.unitName < b.unitName ? -1 : 1;
+                        } else {
+                            return a.unitName > b.unitName ? -1 : 1;
+                        }
+                    });
+                    break;
+                case 'structural-elements':
+                    elements = elements.sort((a, b) => {
+                        if (this.sortASC) {
+                            return a.attributes.title < b.attributes.title ? -1 : 1;
+                        } else {
+                            return a.attributes.title > b.attributes.title ? -1 : 1;
+                        }
+                    });
+                    break;
+                case 'comments':
+                    elements = elements.sort((a, b) => {
+                        if (this.sortASC) {
+                            return a.comments.length - b.comments.length;
+                        } else {
+                            return  b.comments.length - a.comments.length;
+                        }
+                    });
+                    break;
+                case 'feedback':
+                    elements = elements.sort((a, b) => {
+                            if (this.sortASC) {
+                                return a.feedbacks.length - b.feedbacks.length;
+                            } else {
+                                return  b.feedbacks.length - a.feedbacks.length;
+                            }
+                        });
+                    break;
+            }
+
+            return elements;
+        }
+    },
+    mounted() {
+        this.$nextTick(() => {
+            this.collectElementRelations();
+        });
+    }
+}
+</script>
diff --git a/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue
index 249b13aa2a2..3fa2c3c1160 100644
--- a/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue
+++ b/resources/vue/components/courseware/CoursewareStructuralElementFeedback.vue
@@ -4,6 +4,7 @@
         class="cw-structural-element-feedback"
         :class="[emptyFeedback ? 'cw-structural-element-feedback-empty' : '']"
     >
+        <span class="sr-only" aria-live="polite">{{ srMessage }}</span>
         <div class="cw-structural-element-feedback-items" v-show="!emptyFeedback" ref="feedbacks">
             <courseware-talk-bubble
                 v-for="feedback in feedback"
@@ -42,6 +43,7 @@ export default {
         return {
             feedbackText: '',
             placeHolder: this.$gettext('Schreiben Sie ein Feedback...'),
+            srMessage: ''
         };
     },
     computed: {
@@ -99,6 +101,7 @@ export default {
             });
         },
         async postFeedback() {
+            this.updateSrMessage(this.$gettext('Feedback gesendet'));
             const data = {
                 attributes: {
                     feedback: this.feedbackText,
@@ -115,6 +118,10 @@ export default {
             await this.createFeedback( data, { root: true });
             this.feedbackText = '';
             this.loadFeedback();
+        },
+        updateSrMessage(message) {
+            this.srMessage = '';
+            this.srMessage = message;
         }
     },
     async mounted() {
diff --git a/resources/vue/courseware-comments-app.js b/resources/vue/courseware-comments-app.js
new file mode 100644
index 00000000000..0e9a4ba430c
--- /dev/null
+++ b/resources/vue/courseware-comments-app.js
@@ -0,0 +1,114 @@
+import CoursewareCommentsModule from './store/courseware/courseware-comments.module';
+import CommentsApp from './components/courseware/CommentsApp.vue';
+import Vuex from 'vuex';
+import axios from 'axios';
+import { mapResourceModules } from '@elan-ev/reststate-vuex';
+
+const mountApp = async (STUDIP, createApp, element) => {
+    const getHttpClient = () =>
+        axios.create({
+            baseURL: STUDIP.URLHelper.getURL(`jsonapi.php/v1`, {}, true),
+            headers: {
+                'Content-Type': 'application/vnd.api+json',
+            },
+        });
+
+    let elem = document.getElementById(element.substring(1));
+    let entry_id = null;
+    let entry_type = null;
+
+    if (elem !== undefined) {
+        if (elem.attributes !== undefined) {
+            if (elem.attributes['entry-type'] !== undefined) {
+                entry_type = elem.attributes['entry-type'].value;
+            }
+            if (elem.attributes['entry-id'] !== undefined) {
+                entry_id = elem.attributes['entry-id'].value;
+            }
+        }
+    }
+
+    const httpClient = getHttpClient();
+
+    const store = new Vuex.Store({
+        modules: {
+            'courseware-comments': CoursewareCommentsModule,
+            ...mapResourceModules({
+                names: [
+                    'courseware-blocks',
+                    'courseware-block-comments',
+                    'courseware-block-feedback',
+                    'courseware-containers',
+                    'courseware-units',
+                    'courseware-structural-elements',
+                    'courseware-structural-element-comments',
+                    'courseware-structural-element-feedback',
+                    'users',
+                    'course-memberships',
+                    'institutes',
+                    'institute-memberships',
+                ],
+                httpClient,
+            }),
+        },
+    });
+    store.dispatch('setHttpClient', httpClient);
+    store.dispatch('setContext', {
+        id: entry_id,
+        type: entry_type,
+    });
+    store.dispatch('setUserId', STUDIP.USER_ID);
+    await store.dispatch('users/loadById', { id: STUDIP.USER_ID });
+    await store.dispatch('loadTeacherStatus', STUDIP.USER_ID);
+
+    const data = await axios(STUDIP.URLHelper.getURL('dispatch.php/course/courseware/comments_overview_data/'));
+    store.commit(
+        'courseware-units/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['units']).data,
+        { root: true }
+    );
+    store.commit(
+        'courseware-structural-elements/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['elements']).data,
+        { root: true }
+    );
+    store.commit(
+        'courseware-containers/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['containers']).data,
+        { root: true }
+    );
+    store.commit(
+        'courseware-blocks/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['blocks']).data,
+        { root: true }
+    );
+    store.commit(
+        'courseware-block-comments/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['block_comments']).data,
+        { root: true }
+    );
+    store.commit(
+        'courseware-block-feedback/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['block_feedbacks']).data,
+        { root: true }
+    );
+    store.commit(
+        'courseware-structural-element-comments/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['element_comments']).data,
+        { root: true }
+    );
+    store.commit(
+        'courseware-structural-element-feedback/REPLACE_ALL_RECORDS',
+        JSON.parse(data.data['element_feedbacks']).data,
+        { root: true }
+    );
+
+    const app = createApp({
+        render: (h) => h(CommentsApp),
+        store,
+    });
+
+    app.$mount(element);
+};
+
+export default mountApp;
diff --git a/resources/vue/mixins/courseware/comments-overview-helper.js b/resources/vue/mixins/courseware/comments-overview-helper.js
new file mode 100644
index 00000000000..f45d6ea038e
--- /dev/null
+++ b/resources/vue/mixins/courseware/comments-overview-helper.js
@@ -0,0 +1,16 @@
+export default {
+    methods: {
+        calcCreated(mkdate) {
+            let created = {oneDay: false, oneWeek: false};
+            const delta = (new Date() - new Date(mkdate)) / 1000 / 60 / 60 / 24;
+            if (delta < 2) {
+                created.oneDay = true;
+            }
+            if (delta < 8) {
+                created.oneWeek = true;
+            }
+
+            return created;
+        }
+    }
+}
\ No newline at end of file
diff --git a/resources/vue/store/courseware/courseware-comments.module.js b/resources/vue/store/courseware/courseware-comments.module.js
new file mode 100644
index 00000000000..c65ee118d06
--- /dev/null
+++ b/resources/vue/store/courseware/courseware-comments.module.js
@@ -0,0 +1,146 @@
+const getDefaultState = () => {
+    return {
+        context: null,
+        httpClient: null,
+        userId: null,
+        userIsTeacher: false,
+        teacherStatusLoaded: false,
+        typeFilter: 'all', // all, blocks, elements
+        createdFilter: 'all', // all, oneDay, oneWeek
+        unitFilter: 'all', // all or unit id
+    };
+};
+
+const initialState = getDefaultState();
+
+const getters = {
+    context(state) {
+        return state.context;
+    },
+    httpClient(state) {
+        return state.httpClient;
+    },
+    userId(state) {
+        return state.userId;
+    },
+    userIsTeacher(state) {
+        return state.userIsTeacher;
+    },
+    teacherStatusLoaded(state) {
+        return state.teacherStatusLoaded;
+    },
+    typeFilter(state) {
+        return state.typeFilter;
+    },
+    createdFilter(state) {
+        return state.createdFilter;
+    },
+    unitFilter(state) {
+        return state.unitFilter;
+    }
+};
+
+export const state = { ...initialState };
+
+export const actions = {
+    // setters
+    setContext({ commit }, context) {
+        commit('setContext', context);
+    },
+    setHttpClient({ commit }, httpClient) {
+        commit('setHttpClient', httpClient);
+    },
+    setUserId({ commit }, id) {
+        commit('setUserId', id);
+    },
+    setTypeFilter({ commit }, type) {
+        commit('setTypeFilter', type);
+    },
+    setCreatedFilter({ commit }, created) {
+        commit('setCreatedFilter', created);
+    },
+    setUnitFilter({ commit }, id) {
+        commit('setUnitFilter', id);
+    },
+    // other actions
+    async loadTeacherStatus({ dispatch, rootGetters, state, commit, getters }, userId) {
+        const user = rootGetters['users/byId']({ id: userId });
+
+        if (user.attributes.permission === 'root') {
+            commit('setUserIsTeacher', true);
+            return;
+        }
+        if (user.attributes.permission === 'admin') {
+            await dispatch('courses/loadById', { id: state.context.id });
+            const course = rootGetters['courses/byId']({id: state.context.id });
+            const instituteId = course.relationships.institute.data.id;
+
+            const parent = { type: 'users', id: `${userId}` };
+            const relationship = 'institute-memberships';
+            const options = {};
+            await dispatch('institute-memberships/loadRelated', { parent, relationship, options }, { root: true });
+            const instituteMemberships = rootGetters['institute-memberships/all'];
+            const instituteMembership = instituteMemberships.filter(membership => membership.relationships.institute.data.id === instituteId);
+
+            if (instituteMembership.length > 0 && instituteMembership[0].attributes.permission === 'admin') {
+                commit('setUserIsTeacher', true);
+                return;
+            }
+        }
+
+        const membershipId = `${state.context.id}_${userId}`;
+        try {
+            await dispatch('course-memberships/loadById', { id: membershipId });
+        } catch (error) {
+            console.error(`Could not find course membership for ${membershipId}.`);
+            commit('setUserIsTeacher', false);
+
+            return false;
+        }
+        const membership = rootGetters['course-memberships/byId']({ id: membershipId });
+        if (membership) {
+            const membershipPermission = membership.attributes.permission;
+            const isTeacher = membershipPermission === 'dozent' || membershipPermission === 'tutor';
+            commit('setUserIsTeacher', isTeacher);
+
+            return true;
+        } else {
+            console.error(`Could not find course membership for ${membershipId}.`);
+            commit('setUserIsTeacher', false);
+
+            return false;
+        }
+    },
+};
+
+export const mutations = {
+    setContext(state, data) {
+        state.context = data;
+    },
+    setHttpClient(state, data) {
+        state.httpClient = data;
+    },
+    setUserId(state, data) {
+        state.userId = data;
+    },
+    setTypeFilter(state, data) {
+        state.typeFilter = data;
+    },
+    setCreatedFilter(state, data) {
+        state.createdFilter = data;
+    },
+    setUnitFilter(state, data) {
+        state.unitFilter = data;
+    },
+    setUserIsTeacher(state, isTeacher) {
+        state.teacherStatusLoaded = true;
+        state.userIsTeacher = isTeacher;
+    },
+};
+
+export default {
+    state,
+    actions,
+    mutations,
+    getters,
+};
\ No newline at end of file
-- 
GitLab