aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ChangeLog.md2
-rw-r--r--app/controllers/admin/user.php14
-rw-r--r--app/controllers/course/forum/ForumBaseController.php66
-rw-r--r--app/controllers/course/forum/admin.php133
-rw-r--r--app/controllers/course/forum/area.php79
-rw-r--r--app/controllers/course/forum/categories.php122
-rw-r--r--app/controllers/course/forum/configs.php34
-rw-r--r--app/controllers/course/forum/discussion_types.php83
-rw-r--r--app/controllers/course/forum/discussions.php220
-rw-r--r--app/controllers/course/forum/forum_controller.php51
-rw-r--r--app/controllers/course/forum/index.php829
-rw-r--r--app/controllers/course/forum/recent.php24
-rw-r--r--app/controllers/course/forum/search.php217
-rw-r--r--app/controllers/course/forum/subscriptions.php19
-rw-r--r--app/controllers/course/forum/topics.php157
-rw-r--r--app/controllers/course/topics.php3
-rw-r--r--app/controllers/institute/basicdata.php8
-rw-r--r--app/controllers/privacy.php2
-rw-r--r--app/views/course/forum/admin/childs.php31
-rw-r--r--app/views/course/forum/admin/index.php31
-rw-r--r--app/views/course/forum/area/_add_area_form.php15
-rw-r--r--app/views/course/forum/area/_edit_area_form.php7
-rw-r--r--app/views/course/forum/area/_edit_category_form.php8
-rw-r--r--app/views/course/forum/area/_js_templates.php45
-rw-r--r--app/views/course/forum/area/add.php85
-rw-r--r--app/views/course/forum/configs/edit.php52
-rw-r--r--app/views/course/forum/discussion_types/index.php80
-rw-r--r--app/views/course/forum/index/_abo_link.php23
-rw-r--r--app/views/course/forum/index/_areas.php103
-rw-r--r--app/views/course/forum/index/_breadcrumb.php16
-rw-r--r--app/views/course/forum/index/_favorite.php12
-rw-r--r--app/views/course/forum/index/_js_templates.php16
-rw-r--r--app/views/course/forum/index/_last_post.php21
-rw-r--r--app/views/course/forum/index/_like.php48
-rw-r--r--app/views/course/forum/index/_new_category.php19
-rw-r--r--app/views/course/forum/index/_new_entry.php62
-rw-r--r--app/views/course/forum/index/_post.php270
-rw-r--r--app/views/course/forum/index/_postings.php15
-rw-r--r--app/views/course/forum/index/_threads.php197
-rw-r--r--app/views/course/forum/index/index.php264
-rw-r--r--app/views/course/forum/messages.php7
-rw-r--r--cli/Commands/Make/Plugin.php1
-rw-r--r--db/migrations/6.1.6_forum3.php400
-rw-r--r--lib/activities/CourseContext.php1
-rw-r--r--lib/activities/ForumProvider.php48
-rw-r--r--lib/activities/InstituteContext.php1
-rw-r--r--lib/archiv.inc.php6
-rw-r--r--lib/classes/Forum/DTO/ForumMember.php53
-rw-r--r--lib/classes/Forum/DTO/ForumTag.php45
-rw-r--r--lib/classes/Forum/Enum/SubscriptionNotificationType.php25
-rw-r--r--lib/classes/Forum/Service/DiscussionNotification.php62
-rw-r--r--lib/classes/Forum/Service/PostingNotification.php140
-rw-r--r--lib/classes/ForumAbo.php187
-rw-r--r--lib/classes/ForumActivity.php149
-rw-r--r--lib/classes/ForumEntry.php1418
-rw-r--r--lib/classes/ForumFavorite.php41
-rw-r--r--lib/classes/ForumHelpers.php278
-rw-r--r--lib/classes/ForumIssue.php96
-rw-r--r--lib/classes/ForumLike.php105
-rw-r--r--lib/classes/ForumPerm.php217
-rw-r--r--lib/classes/ForumVisit.php171
-rw-r--r--lib/classes/JsonApi/Models/ForumCat.php46
-rw-r--r--lib/classes/JsonApi/Models/ForumEntry.php146
-rw-r--r--lib/classes/JsonApi/RouteMap.php68
-rw-r--r--lib/classes/JsonApi/Routes/Forum/AbstractEntriesCreate.php65
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumAuthority.php25
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoriesCreate.php61
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoriesDelete.php42
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoriesShow.php33
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoriesUpdate.php63
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesCreate.php43
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesIndex.php44
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoryIndex.php (renamed from lib/classes/JsonApi/Routes/Forum/ForumCategoriesIndex.php)33
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoryShow.php36
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoryTopics.php46
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumCategoryUpdateSort.php62
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumConfigIndex.php32
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumDiscussionIndex.php60
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumDiscussionPostings.php51
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumDiscussionShow.php39
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeDiscussions.php31
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeIndex.php25
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeShow.php25
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumEntriesDelete.php37
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumEntriesShow.php33
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumEntriesUpdate.php72
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesCreate.php38
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesIndex.php45
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingDelete.php38
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingReactionDelete.php32
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingReactionShow.php26
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingReactionStore.php77
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingReactions.php43
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingShow.php40
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingStore.php100
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumPostingUpdate.php69
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumSubscriptionDelete.php32
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumSubscriptionIndex.php45
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumSubscriptionShow.php36
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumSubscriptionStore.php86
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumTopicDiscussions.php50
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumTopicIndex.php38
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumTopicShow.php33
-rw-r--r--lib/classes/JsonApi/Routes/Forum/ForumTopicUpdateSort.php61
-rw-r--r--lib/classes/JsonApi/SchemaMap.php11
-rw-r--r--lib/classes/JsonApi/Schemas/Activity.php2
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumCategory.php74
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumDiscussion.php154
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumDiscussionType.php54
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumMember.php30
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumPosting.php123
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumPostingReaction.php71
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumSubscription.php84
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumTag.php27
-rw-r--r--lib/classes/JsonApi/Schemas/Forum/ForumTopic.php107
-rw-r--r--lib/classes/JsonApi/Schemas/ForumCategory.php76
-rw-r--r--lib/classes/JsonApi/Schemas/ForumEntry.php78
-rw-r--r--lib/classes/Privacy.php2
-rw-r--r--lib/classes/Score.php2
-rw-r--r--lib/classes/Siteinfo.php40
-rw-r--r--lib/classes/StudipKing.php18
-rw-r--r--lib/classes/UserManagement.php7
-rw-r--r--lib/classes/forms/ColorInput.php18
-rw-r--r--lib/classes/globalsearch/GlobalSearchForum.php9
-rw-r--r--lib/cronjobs/garbage_collector.php7
-rw-r--r--lib/models/Course.php6
-rw-r--r--lib/models/CourseTopic.php66
-rw-r--r--lib/models/Forum/ForumCategory.php107
-rw-r--r--lib/models/Forum/ForumDiscussion.php201
-rw-r--r--lib/models/Forum/ForumDiscussionType.php36
-rw-r--r--lib/models/Forum/ForumPosting.php118
-rw-r--r--lib/models/Forum/ForumPostingReaction.php49
-rw-r--r--lib/models/Forum/ForumPostingRead.php64
-rw-r--r--lib/models/Forum/ForumSubscription.php53
-rw-r--r--lib/models/Forum/ForumTopic.php153
-rw-r--r--lib/models/ForumCat.php258
-rw-r--r--lib/models/PersonalNotifications.php2
-rw-r--r--lib/models/User.php5
-rw-r--r--lib/modules/CoreForum.php211
-rw-r--r--lib/navigation/AdminNavigation.php8
-rw-r--r--lib/object.inc.php2
-rw-r--r--lib/plugins/core/ForumModule.php140
-rw-r--r--package-lock.json74
-rw-r--r--package.json3
-rw-r--r--public/assets/images/forum/forum-keyvisual-positive.svg1
-rw-r--r--public/assets/images/icons/black/add-reaction.svg1
-rw-r--r--public/assets/images/icons/black/pin.svg1
-rw-r--r--public/assets/images/icons/black/quote.svg1
-rw-r--r--public/assets/images/icons/black/quote2.svg1
-rw-r--r--public/assets/images/icons/black/subscription-all.svg1
-rw-r--r--public/assets/images/icons/black/subscription-end.svg1
-rw-r--r--public/assets/images/icons/black/subscription-none.svg1
-rw-r--r--public/assets/images/icons/black/subscription-quotes.svg1
-rw-r--r--public/assets/images/icons/blue/add-reaction.svg1
-rw-r--r--public/assets/images/icons/blue/pin.svg1
-rw-r--r--public/assets/images/icons/blue/quote.svg1
-rw-r--r--public/assets/images/icons/blue/quote2.svg1
-rw-r--r--public/assets/images/icons/blue/subscription-all.svg1
-rw-r--r--public/assets/images/icons/blue/subscription-end.svg1
-rw-r--r--public/assets/images/icons/blue/subscription-none.svg1
-rw-r--r--public/assets/images/icons/blue/subscription-quotes.svg1
-rw-r--r--public/assets/images/icons/green/add-reaction.svg1
-rw-r--r--public/assets/images/icons/green/pin.svg1
-rw-r--r--public/assets/images/icons/green/quote.svg1
-rw-r--r--public/assets/images/icons/green/quote2.svg1
-rw-r--r--public/assets/images/icons/green/subscription-all.svg1
-rw-r--r--public/assets/images/icons/green/subscription-end.svg1
-rw-r--r--public/assets/images/icons/green/subscription-none.svg1
-rw-r--r--public/assets/images/icons/green/subscription-quotes.svg1
-rw-r--r--public/assets/images/icons/grey/add-reaction.svg1
-rw-r--r--public/assets/images/icons/grey/pin.svg1
-rw-r--r--public/assets/images/icons/grey/quote.svg1
-rw-r--r--public/assets/images/icons/grey/quote2.svg1
-rw-r--r--public/assets/images/icons/grey/subscription-all.svg1
-rw-r--r--public/assets/images/icons/grey/subscription-end.svg1
-rw-r--r--public/assets/images/icons/grey/subscription-none.svg1
-rw-r--r--public/assets/images/icons/grey/subscription-quotes.svg1
-rw-r--r--public/assets/images/icons/red/add-reaction.svg1
-rw-r--r--public/assets/images/icons/red/pin.svg1
-rw-r--r--public/assets/images/icons/red/quote.svg1
-rw-r--r--public/assets/images/icons/red/quote2.svg1
-rw-r--r--public/assets/images/icons/red/subscription-all.svg1
-rw-r--r--public/assets/images/icons/red/subscription-end.svg1
-rw-r--r--public/assets/images/icons/red/subscription-none.svg1
-rw-r--r--public/assets/images/icons/red/subscription-quotes.svg1
-rw-r--r--public/assets/images/icons/white/add-reaction.svg1
-rw-r--r--public/assets/images/icons/white/pin.svg1
-rw-r--r--public/assets/images/icons/white/quote.svg1
-rw-r--r--public/assets/images/icons/white/quote2.svg1
-rw-r--r--public/assets/images/icons/white/subscription-all.svg1
-rw-r--r--public/assets/images/icons/white/subscription-end.svg1
-rw-r--r--public/assets/images/icons/white/subscription-none.svg1
-rw-r--r--public/assets/images/icons/white/subscription-quotes.svg1
-rw-r--r--public/assets/images/icons/yellow/add-reaction.svg1
-rw-r--r--public/assets/images/icons/yellow/pin.svg1
-rw-r--r--public/assets/images/icons/yellow/quote.svg1
-rw-r--r--public/assets/images/icons/yellow/quote2.svg1
-rw-r--r--public/assets/images/icons/yellow/subscription-all.svg1
-rw-r--r--public/assets/images/icons/yellow/subscription-end.svg1
-rw-r--r--public/assets/images/icons/yellow/subscription-none.svg1
-rw-r--r--public/assets/images/icons/yellow/subscription-quotes.svg1
-rw-r--r--resources/assets/javascripts/init.js2
-rw-r--r--resources/assets/javascripts/lib/forum.js853
-rw-r--r--resources/assets/javascripts/lib/jsonapiUtils.js15
-rw-r--r--resources/assets/javascripts/lib/number_formatter.js21
-rw-r--r--resources/assets/stylesheets/scss/forms.scss4
-rw-r--r--resources/assets/stylesheets/scss/forum.scss1768
-rw-r--r--resources/assets/stylesheets/scss/links.scss11
-rw-r--r--resources/assets/stylesheets/scss/personal-notifications.scss12
-rw-r--r--resources/assets/stylesheets/scss/select.scss37
-rw-r--r--resources/assets/stylesheets/studip.scss250
-rw-r--r--resources/vue/apps/forum/categories/Edit.vue92
-rw-r--r--resources/vue/apps/forum/categories/Index.vue270
-rw-r--r--resources/vue/apps/forum/categories/Show.vue125
-rw-r--r--resources/vue/apps/forum/discussions/Edit.vue164
-rw-r--r--resources/vue/apps/forum/discussions/Show.vue325
-rw-r--r--resources/vue/apps/forum/discussions_types/Edit.vue91
-rw-r--r--resources/vue/apps/forum/recent/Index.vue71
-rw-r--r--resources/vue/apps/forum/search/Index.vue282
-rw-r--r--resources/vue/apps/forum/subscriptions/Index.vue233
-rw-r--r--resources/vue/apps/forum/topics/Edit.vue125
-rw-r--r--resources/vue/apps/forum/topics/Index.vue100
-rw-r--r--resources/vue/apps/forum/topics/Show.vue129
-rw-r--r--resources/vue/components/Dropdown.vue55
-rw-r--r--resources/vue/components/LinksPreview.vue51
-rw-r--r--resources/vue/components/StudipDateTime.vue113
-rw-r--r--resources/vue/components/StudipSwitch.vue155
-rw-r--r--resources/vue/components/UserAvatar.vue102
-rw-r--r--resources/vue/components/forum/EmptyForum.vue40
-rw-r--r--resources/vue/components/forum/ForumApp.vue34
-rw-r--r--resources/vue/components/forum/ForumMembers.vue134
-rw-r--r--resources/vue/components/forum/Loader.vue19
-rw-r--r--resources/vue/components/forum/SelectTagsInput.vue35
-rw-r--r--resources/vue/components/forum/SelectUserInput.vue30
-rw-r--r--resources/vue/components/forum/SubscriptionDropdown.vue207
-rw-r--r--resources/vue/components/forum/UserAvatarDropdown.vue44
-rw-r--r--resources/vue/components/forum/categories/CategoryItem.vue212
-rw-r--r--resources/vue/components/forum/categories/Create.vue27
-rw-r--r--resources/vue/components/forum/discussions/Create.vue30
-rw-r--r--resources/vue/components/forum/discussions/DiscussionIndex.vue290
-rw-r--r--resources/vue/components/forum/discussions/DiscussionTimeline.vue88
-rw-r--r--resources/vue/components/forum/discussions/SelectDiscussionType.vue35
-rw-r--r--resources/vue/components/forum/enums/SubscriptionNotificationType.ts5
-rw-r--r--resources/vue/components/forum/helpers/index.js16
-rw-r--r--resources/vue/components/forum/helpers/transformers.js18
-rw-r--r--resources/vue/components/forum/helpers/urls.js17
-rw-r--r--resources/vue/components/forum/posts/Post.vue235
-rw-r--r--resources/vue/components/forum/posts/PostContent.vue66
-rw-r--r--resources/vue/components/forum/posts/PostCreateForm.vue161
-rw-r--r--resources/vue/components/forum/posts/PostEditForm.vue107
-rw-r--r--resources/vue/components/forum/posts/PostReactions.vue141
-rw-r--r--resources/vue/components/forum/posts/reactions.js35
-rw-r--r--resources/vue/components/forum/topics/CreateTopic.vue37
-rw-r--r--resources/vue/components/forum/topics/SelectTopicInput.vue53
-rw-r--r--resources/vue/components/forum/topics/TopicItem.vue203
-rw-r--r--resources/vue/components/forum/topics/TopicsIndex.vue228
-rw-r--r--resources/vue/composables/useDetectOutsideClick.js18
-rw-r--r--resources/vue/composables/useSortable.js72
-rw-r--r--resources/vue/store/pinia/forum/ForumConfig.js34
-rw-r--r--resources/vue/store/pinia/forum/ForumPost.js53
-rw-r--r--templates/forms/color_input.php17
-rw-r--r--templates/personal_notifications/notification.php9
-rw-r--r--tests/jsonapi/ForumCategoriesCreateTest.php67
-rw-r--r--tests/jsonapi/ForumCategoriesIndexTest.php60
-rw-r--r--tests/jsonapi/ForumCategoriesShowTest.php62
-rw-r--r--tests/jsonapi/ForumCategoriesUpdateTest.php64
-rw-r--r--tests/jsonapi/ForumCategoryDeleteTest.php60
-rw-r--r--tests/jsonapi/ForumEntriesCreateTest.php115
-rw-r--r--tests/jsonapi/ForumEntriesDeleteTest.php61
-rw-r--r--tests/jsonapi/ForumEntriesShowTest.php154
-rw-r--r--tests/jsonapi/ForumEntriesUpdateTest.php66
-rw-r--r--tests/jsonapi/ForumTestHelper.php116
272 files changed, 11862 insertions, 8817 deletions
diff --git a/ChangeLog.md b/ChangeLog.md
index 5d26e7c..3ae0293 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -2954,7 +2954,7 @@ https://gitlab.studip.de/studip/studip/-/issues?milestone_title=Stud.IP+4.5.8&st
- Löschen von Einzelterminen führt zu kaputten Raumanfragen [#707]
- "Anfrage auf ausgewählte Termine stellen" funktioniert nicht mehr [#711]
- Personenliste anlegen ohne Platzverteilung wirft Fehler [#927]
-- PHP message: InvalidArgumentException: navigation item 'course/forum2/newest' not found [#944]
+- PHP message: InvalidArgumentException: navigation item 'course/forum/newest' not found [#944]
- Unklares Verhalten bei `url_for` mit URL-Fragmenten [#985]
- Anzeigen/Drucken des QR-Codes verhält sich komisch bzw. ist kaputt [#995]
- Dialog/Seite zu Fragebögen verlinkt nicht auf die vorhandene Hilfe [#1011]
diff --git a/app/controllers/admin/user.php b/app/controllers/admin/user.php
index 88b5272..5a0cd9d 100644
--- a/app/controllers/admin/user.php
+++ b/app/controllers/admin/user.php
@@ -1517,15 +1517,11 @@ class Admin_UserController extends AuthenticatedController
'details' => "files",
];
- foreach (PluginEngine::getPlugins(ForumModule::class) as $plugin) {
- $table = $plugin->getEntryTableInfo();
- $queries[] = [
- 'desc' => $plugin->getPluginName() . ' - ' . _("Anzahl der Postings"),
- 'query' => 'SELECT COUNT(*) FROM `' . $table['table'] . '`
- WHERE `' . $table['user_id'] . '` = ?
- GROUP BY `' . $table['user_id'] . '`',
- ];
- }
+ // Forum
+ $queries[] = [
+ 'desc' => _('Forum - Anzahl der Postings'),
+ 'query' => "SELECT COUNT(*) FROM `forum_postings` WHERE `user_id` = ? GROUP BY `user_id`"
+ ];
// Evaluate queries
foreach ($queries as $index => $query) {
diff --git a/app/controllers/course/forum/ForumBaseController.php b/app/controllers/course/forum/ForumBaseController.php
new file mode 100644
index 0000000..cefa5a4
--- /dev/null
+++ b/app/controllers/course/forum/ForumBaseController.php
@@ -0,0 +1,66 @@
+<?php
+namespace Forum;
+
+use ActionsWidget;
+use Context;
+use CoreForum;
+use Icon;
+use Request;
+use SearchWidget;
+use Sidebar;
+use StudipController;
+
+abstract class ForumBaseController extends StudipController
+{
+ protected $with_session = true;
+
+ public function before_filter(&$action, &$args)
+ {
+ object_set_visit_module('forum');
+
+ $this->course_id = Context::getId();
+ $this->is_moderator = CoreForum::isModerator($this->course_id);
+ $this->is_admin = CoreForum::isAdmin($this->course_id);
+
+ $this->buildSidebar();
+
+ parent::before_filter($action, $args);
+ }
+
+ protected function buildSidebar(): void
+ {
+ $actions = new ActionsWidget();
+
+ if ($this->is_moderator) {
+ $actions->addLink(
+ _('Neue Diskussion starten'),
+ $this->url_for('course/forum/discussions/edit'),
+ Icon::create('add', Icon::ROLE_CLICKABLE, ['title' => _('Neue Diskussion starten')])
+ )->asDialog('width=900;height=750');
+ }
+
+ if ($this->is_admin) {
+ $actions->addLink(
+ _('Forum verwalten'),
+ $this->url_for('course/forum/configs/edit'),
+ Icon::create('admin', Icon::ROLE_CLICKABLE, ['title' => _('Forum verwalten')]),
+ ['data-dialog' => 'width=500;height=300']
+ );
+ }
+
+ Sidebar::Get()->addWidget($actions);
+
+ $search = new SearchWidget($this->url_for('course/forum/search', [
+ 'begin' => Request::int('begin'),
+ 'end' => Request::int('end')
+ ]));
+
+ $search->addNeedle(
+ _('Suche nach Diskussionen oder Beiträge'),
+ 'keyword',
+ true
+ );
+
+ Sidebar::Get()->addWidget($search, 'forum_search');
+ }
+}
diff --git a/app/controllers/course/forum/admin.php b/app/controllers/course/forum/admin.php
deleted file mode 100644
index e4d21d8..0000000
--- a/app/controllers/course/forum/admin.php
+++ /dev/null
@@ -1,133 +0,0 @@
-<?php
-
-/*
- * Copyright (C) 2011 - Till Glöggler <tgloeggl@uos.de>
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU General Public License as
- * published by the Free Software Foundation; either version 2 of
- * the License, or (at your option) any later version.
- */
-
-require_once 'forum_controller.php';
-
-class Course_Forum_AdminController extends ForumController
-{
- /* * * * * * * * * * * * * * * * * * * * */
- /* * * A D M I N M E T H O D S * * */
- /* * * * * * * * * * * * * * * * * * * * */
-
- /**
- * show the administration page for mass-editing forum-entries
- */
- function index_action()
- {
- ForumPerm::check('admin', $this->getId());
- $nav = Navigation::getItem('course/forum2');
- $nav->setImage(Icon::create('forum', 'info'));
- Navigation::activateItem('course/forum2/admin');
-
- $list = ForumEntry::getList('flat', $this->getId());
-
- // sort by cat
- $new_list = [];
- $this->categories = [];
- // iterate over all categories and add the belonging areas to them
- $categories = ForumCat::getListWithAreas($this->getId(), false) ;
- foreach ($categories as $category) {
- if (!empty($category['topic_id'])) {
- $new_list[$category['category_id']][$category['topic_id']] = $list['list'][$category['topic_id']];
- unset($list['list'][$category['topic_id']]);
- } else if (ForumPerm::has('add_area', $this->seminar_id)) {
- $new_list[$category['category_id']] = [];
- }
- $this->categories[$category['category_id']] = $category['entry_name'];
- }
-
- if (!empty($list['list'])) {
- // append the remaining entries to the standard category
- $new_list[$this->getId()] = array_merge((array)$new_list[$this->getId()], $list['list']);
- }
-
- $this->list = $new_list;
-
- }
-
- /**
- * show child entries for the passed entry
- *
- * @param string $parent_id id of entry to get the childs for
- */
- function childs_action($parent_id)
- {
- $this->set_layout(null);
-
- // if the parent-id is a category-id, get all areas for this category
- if ($cat = ForumCat::get($parent_id)) {
- ForumPerm::check('admin', $cat['seminar_id']); // check the perms in the categories seminar
- $this->entries = ForumEntry::parseEntries(ForumCat::getAreas($parent_id));
- } else {
- ForumPerm::check('admin', $this->getId(), $parent_id);
- $entries = ForumEntry::getList('flat', $parent_id);
- $this->entries = $entries['list'];
- }
- }
-
- /**
- * move the submitted topics[] to the passed destination
- *
- * @param string $destination id of seminar to move topics to
- */
- function move_action($destination)
- {
- // check if destination is a category_id. if yes, use seminar_id instead
- if (ForumCat::get($destination)) {
- $category_id = $destination;
- $destination = $this->getId();
- } else {
- $category_id = null;
- }
-
- ForumPerm::check('admin', $this->getId(), $destination);
-
- foreach (Request::getArray('topics') as $topic_id) {
- // make sure every passed topic_id is checked against the current seminar
- ForumPerm::check('admin', $this->getId(), $topic_id);
-
- // if the source is an area and the target a category, just move this area to the category
- $entry = ForumEntry::getEntry($topic_id);
- if ($entry['depth'] == 1 && $category_id) {
- ForumCat::removeArea($topic_id);
- ForumCat::addArea($category_id, $topic_id);
- } else {
- // first step: move the whole topic with all childs
- ForumEntry::move($topic_id, $destination);
-
- // if the current topic id is an area, remove it from any categories
- ForumCat::removeArea($topic_id);
-
- // second step: move all to deep childs a level up (depth > 3)
- $data = ForumEntry::getList('depth_to_large', $topic_id);
-
- if (!empty($data['list'])) {
- foreach ($data['list'] as $entry) {
- $path = ForumEntry::getPathToPosting($entry['topic_id']);
- array_shift($path); // Category
- array_shift($path); // Area
-
- $thread = array_shift($path); // Thread
-
- ForumEntry::move($entry['topic_id'], $thread['id']);
- }
- }
-
- // add entry to passed category when moving to the top
- if ($category_id) {
- ForumCat::addArea($category_id, $topic_id);
- }
- }
- }
-
- $this->render_nothing();
- }
-}
diff --git a/app/controllers/course/forum/area.php b/app/controllers/course/forum/area.php
deleted file mode 100644
index 821129f..0000000
--- a/app/controllers/course/forum/area.php
+++ /dev/null
@@ -1,79 +0,0 @@
-<?php
-
-/*
- * Copyright (C) 2011 - Till Glöggler <tgloeggl@uos.de>
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU General Public License as
- * published by the Free Software Foundation; either version 2 of
- * the License, or (at your option) any later version.
- */
-
-require_once 'forum_controller.php';
-
-class Course_Forum_AreaController extends ForumController
-{
- function add_action($category_id)
- {
- ForumPerm::check('add_area', $this->getId());
-
- $new_id = md5(uniqid(rand()));
-
- $name = Request::get('name', _('Kein Titel'));
- $content = Request::get('content');
-
- ForumEntry::insert([
- 'topic_id' => $new_id,
- 'seminar_id' => $this->getId(),
- 'user_id' => $GLOBALS['user']->id,
- 'name' => $name,
- 'content' => $content,
- 'author' => get_fullname($GLOBALS['user']->id),
- 'author_host' => ($GLOBALS['user']->id == 'nobody') ? getenv('REMOTE_ADDR') : ''
- ], $this->getId());
-
- ForumCat::addArea($category_id, $new_id);
-
- if (Request::isXhr()) {
- $this->set_layout(null);
- $entries = ForumEntry::parseEntries([ForumEntry::getEntry($new_id)]);
- $this->entry = array_pop($entries);
- $this->visitdate = ForumVisit::getLastVisit($this->getId());
- } else {
- $this->redirect('course/forum/index/index/');
- }
- }
-
- function edit_action($area_id)
- {
- ForumPerm::check('edit_area', $this->getId(), $area_id);
-
- ForumEntry::update($area_id, Request::get('name'), Request::get('content'));
- if (Request::isAjax()) {
- $this->render_json(['content' => ForumEntry::killFormat(ForumEntry::killEdit(Request::get('content')))]);
- } else {
- $this->flash['messages'] = ['success' => _('Die Änderungen am Bereich wurden gespeichert.')];
- $this->redirect('course/forum/index/index');
- }
-
- }
-
- function save_order_action()
- {
- ForumPerm::check('sort_area', $this->getId());
-
- foreach (Request::getArray('areas') as $category_id => $areas) {
- $pos = 0;
- foreach ($areas as $area_id) {
- ForumPerm::checkCategoryId($this->getId(), $category_id);
- ForumPerm::check('sort_area', $this->getId(), $area_id);
-
- ForumCat::addArea($category_id, $area_id);
- ForumCat::setAreaPosition($area_id, $pos);
- $pos++;
- }
- }
-
- $this->render_nothing();
- }
-}
diff --git a/app/controllers/course/forum/categories.php b/app/controllers/course/forum/categories.php
new file mode 100644
index 0000000..2ff5dc6
--- /dev/null
+++ b/app/controllers/course/forum/categories.php
@@ -0,0 +1,122 @@
+<?php
+require_once 'ForumBaseController.php';
+
+use Forum\ForumCategory;
+
+class Course_Forum_CategoriesController extends Forum\ForumBaseController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ if (!CourseConfig::get($this->course_id)->FORUM_HIDE_CATEGORIES_NAVIGATION) {
+ Navigation::activateItem('course/forum/categories');
+ } else {
+ Navigation::activateItem('course/forum/topics');
+ }
+ }
+
+ public function index_action()
+ {
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/categories/Index')
+ );
+ }
+
+ public function show_action($category_id)
+ {
+ $category = ForumCategory::find($category_id);
+
+ if (!$category) {
+ throw new AccessDeniedException();
+ }
+
+ PageLayout::setTitle($category->name);
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/categories/Show')
+ ->withProps([
+ 'category' => $category->transformData(),
+ 'metadata' => [
+ 'postings_count' => (int) $category->metadata['postings_count'],
+ 'users_count' => (int) $category->metadata['users_count'],
+ 'recent_activity' => date('c', $category->metadata['recent_activity'])
+ ]
+ ])
+ );
+ }
+
+ public function edit_action($category_id = null)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ if ($category_id) {
+ PageLayout::setTitle(_('Kategorie bearbeiten'));
+ $category = ForumCategory::findOneBySQL("range_id = ? AND category_id = ?", [$this->course_id, $category_id]);
+
+ if (!$category) {
+ throw new AccessDeniedException();
+ }
+ } else {
+ PageLayout::setTitle(_('Neue Kategorie anlegen'));
+ $category = new ForumCategory();
+ }
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/categories/Edit')
+ ->withProps([
+ 'category' => $category->transformData()
+ ])
+ );
+ }
+
+ public function save_action($category_id = null)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ CSRFProtection::verifyUnsafeRequest();
+
+ if ($category_id) {
+ $category = ForumCategory::findOneBySQL("range_id = ? AND category_id = ?", [$this->course_id, $category_id]);
+ if (!$category) {
+ throw new AccessDeniedException();
+ }
+ } else {
+ $category = new ForumCategory();
+ $category->range_id = $this->course_id;
+ }
+
+ $category->name = Request::get('name');
+ $category->description = Request::get('description');
+ $category->color = Request::get('color');
+
+ $category->store();
+
+ PageLayout::postSuccess(sprintf(_('Die Kategorie „%s“ wurde gespeichert.'), $category->name));
+
+ $this->relocate('course/forum/categories');
+ }
+
+ public function delete_action($category_id)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ $category = ForumCategory::findOneBySQL("range_id = ? AND category_id = ?", [$this->course_id, $category_id]);
+
+ if (!$category) {
+ throw new AccessDeniedException();
+ }
+
+ $category->delete();
+
+ PageLayout::postSuccess(_('Die Kategorie wurde gelöscht.'));
+
+ $this->relocate('course/forum/categories');
+ }
+}
diff --git a/app/controllers/course/forum/configs.php b/app/controllers/course/forum/configs.php
new file mode 100644
index 0000000..70fdb2b
--- /dev/null
+++ b/app/controllers/course/forum/configs.php
@@ -0,0 +1,34 @@
+<?php
+require_once 'ForumBaseController.php';
+
+class Course_Forum_ConfigsController extends Forum\ForumBaseController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ if (! $this->is_admin) {
+ throw new AccessDeniedException();
+ }
+ }
+
+ public function edit_action()
+ {
+ $this->config = Context::get()->getConfiguration();
+ }
+
+ public function save_action()
+ {
+ CSRFProtection::verifyUnsafeRequest();
+
+ $this->config = Context::get()->getConfiguration();
+
+ $this->config->store('FORUM_MODERATION_PERMISSION', trim(Request::option('forum_moderation_permission')));
+
+ $this->config->store('FORUM_HIDE_CATEGORIES_NAVIGATION', Request::bool('forum_hide_categories_navigation'));
+
+ PageLayout::postSuccess(_('Die Einstellungen wurden gespeichert.'));
+
+ $this->relocate('course/forum/topics');
+ }
+}
diff --git a/app/controllers/course/forum/discussion_types.php b/app/controllers/course/forum/discussion_types.php
new file mode 100644
index 0000000..da4ea27
--- /dev/null
+++ b/app/controllers/course/forum/discussion_types.php
@@ -0,0 +1,83 @@
+<?php
+use Forum\ForumDiscussionType;
+
+class Course_Forum_DiscussionTypesController extends AuthenticatedController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ PageLayout::setTitle(_('Forum Diskussions-Typ'));
+
+ $GLOBALS['perm']->check('root');
+
+ Navigation::activateItem('/admin/locations/forum_discussion_types');
+
+ $actions = new ActionsWidget();
+
+ $actions->addLink(
+ _('Neuen Diskussionstyp anlegen'),
+ $this->url_for('course/forum/discussion_types/edit'),
+ Icon::create('add', Icon::ROLE_CLICKABLE, ['title' => _('Neuen Diskussionstyp anlegen')])
+ )->asDialog('width=700');
+
+ Sidebar::Get()->addWidget($actions);
+ }
+
+ public function index_action()
+ {
+ $this->discussion_types = ForumDiscussionType::findBySQL("TRUE ORDER BY mkdate DESC");
+ }
+
+ public function edit_action(ForumDiscussionType $discussion_type = null)
+ {
+ if ($discussion_type->isNew()) {
+ PageLayout::setTitle(_('Neuen Diskussionstyp anlegen'));
+ } else {
+ PageLayout::setTitle(_('Diskussionstyp bearbeiten'));
+ }
+
+ $icons = [];
+
+ foreach (scandir($GLOBALS['STUDIP_BASE_PATH'] . '/public/assets/images/icons/blue') as $icon_path) {
+ if (!is_dir(
+ $GLOBALS['STUDIP_BASE_PATH'] . '/public/assets/images/icons/blue/'
+ ) . $icon_path && $icon_path[0] !== '.') {
+ if (stripos($icon_path, '.svg')) {
+ $icon_path = substr($icon_path, 0, stripos($icon_path, '.svg'));
+ }
+ $icons[] = $icon_path;
+ }
+ }
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/discussions_types/Edit')->withProps([
+ 'icons' => array_unique($icons),
+ 'discussion_type' => $discussion_type->toRawArray()
+ ])
+ );
+ }
+
+ public function save_action(ForumDiscussionType $discussion_type = null)
+ {
+ CSRFProtection::verifyUnsafeRequest();
+
+ $discussion_type->name = Request::get('name');
+ $discussion_type->icon = Request::get('icon');
+
+ $discussion_type->store();
+
+ PageLayout::postSuccess(sprintf(_('Der Diskussionstyp „%s“ wurde gespeichert.'), $discussion_type->name));
+
+ $this->relocate('course/forum/discussion_types/index');
+ }
+
+ public function delete_action(ForumDiscussionType $discussion_type)
+ {
+ $discussion_type->delete();
+
+ PageLayout::postSuccess(_('Der Diskussionstyp wurde gelöscht.'));
+
+ $this->relocate('course/forum/discussion_types/index');
+ }
+}
diff --git a/app/controllers/course/forum/discussions.php b/app/controllers/course/forum/discussions.php
new file mode 100644
index 0000000..745c370
--- /dev/null
+++ b/app/controllers/course/forum/discussions.php
@@ -0,0 +1,220 @@
+<?php
+require_once 'ForumBaseController.php';
+
+use Studip\Markup;
+use Forum\ForumDiscussion;
+use Forum\ForumDiscussionType;
+use Forum\DTO\ForumMember;
+use Forum\ForumPosting;
+use Forum\ForumPostingRead;
+use Forum\ForumSubscription;
+use Forum\DTO\ForumTag;
+use Forum\ForumTopic;
+
+class Course_Forum_DiscussionsController extends Forum\ForumBaseController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ Navigation::activateItem('course/forum/topics');
+ }
+
+ public function show_action($discussion_id)
+ {
+ $discussion = ForumDiscussion::find($discussion_id);
+
+ if (!$discussion) {
+ throw new AccessDeniedException();
+ }
+
+ PageLayout::setTitle($discussion->title);
+
+ $auth_user = User::findCurrent();
+
+ $discussion->view_count += 1;
+ $discussion->store();
+
+ $posting_read = ForumPostingRead::findOneBySQL(
+ "discussion_id = :discussion_id AND user_id = :user_id",
+ [
+ 'discussion_id' => $discussion->getId(),
+ 'user_id' => User::findCurrent()->user_id
+ ]
+ );
+
+ $user_subscription = ForumSubscription::findOneBySQL(
+ "subject = :subject AND subject_id = :subject_id AND user_id = :user_id",
+ [
+ 'subject' => 'discussion',
+ 'subject_id' => $discussion->getId(),
+ 'user_id' => $auth_user->user_id
+ ]
+ );
+
+ $category = $discussion->getCategory();
+ $tags = array_map(fn(ForumTag $tag) => $tag->toRawArray(), $discussion->tags);
+ $members = array_map(fn(ForumMember $member) => $member->toRawArray(), $discussion->members);
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/discussions/Show')
+ ->withProps([
+ 'auth_user' => [
+ 'id' => $auth_user->id,
+ 'username' => $auth_user->username,
+ 'name' => $auth_user->getFullName(),
+ 'avatar_url' => Avatar::getAvatar($auth_user->user_id)->getURL(Avatar::NORMAL),
+ 'subscription' => $user_subscription ? $user_subscription->toRawArray() : []
+ ],
+ 'discussion' => [
+ ...$discussion->transformData(),
+ 'topic' => $discussion->topic->toRawArray(),
+ 'tags' => $tags,
+ 'members' => $members,
+ 'type' => !empty($discussion->discussion_type) ? $discussion->discussion_type->toRawArray() : []
+ ],
+ 'category' => $category ? $category->toRawArray() : [],
+ 'read_index' => (int) ($posting_read ? $posting_read->read_index : 0),
+ 'redirect' => Request::option('redirect'),
+ 'search_keyword' => $_SESSION['forum'][$this->course_id]['search']['keyword'] ?? ''
+ ])
+ );
+ }
+
+ public function edit_action(ForumDiscussion $discussion = null)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ if ($discussion->isNew()) {
+ PageLayout::setTitle(_('Neue Diskussion starten'));
+ } else {
+ PageLayout::setTitle(_('Diskussion bearbeiten'));
+ }
+
+ $topics = DBManager::get()->fetchAll(
+ "
+ SELECT
+ `ft`.`topic_id`, `ft`.`name`, `fc`.`color`
+ FROM `forum_topics` AS `ft`
+ LEFT JOIN `forum_categories` AS `fc` USING (`category_id`)
+ WHERE `ft`.`range_id` = :course_id
+ ORDER BY `ft`.`position` ASC, `ft`.`mkdate` DESC
+ ",
+ ['course_id' => $this->course_id]
+ );
+
+ $all_tags = array_map(fn(ForumTag $tag) => $tag->toRawArray(), ForumTag::getForumTags());
+ $discussion_tags = array_map(fn(ForumTag $tag) => $tag->toRawArray(), $discussion->tags);
+ $discussion_types = array_map(fn(ForumDiscussionType $discussion_type) => $discussion_type->toRawArray(), ForumDiscussionType::getForumDiscussionType());
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/discussions/Edit')
+ ->withProps([
+ 'discussion' => [
+ ...$discussion->transformData(),
+ 'topic_id' => !empty($discussion->topic_id) ? $discussion->topic_id : Request::option('topic_id'),
+ 'tags' => $discussion_tags
+ ],
+ 'topics' => $topics,
+ 'tags' => $all_tags,
+ 'discussion_types' => $discussion_types
+ ])
+ );
+ }
+
+ public function save_action($discussion_id = null)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ CSRFProtection::verifyUnsafeRequest();
+
+ if ($discussion_id) {
+ $discussion = ForumDiscussion::find($discussion_id);
+ } else {
+ $discussion = new ForumDiscussion();
+ }
+
+ $discussion->title = Request::get('title');
+ $discussion->closed_at = Request::bool('closed_at', false) ? time() : null;
+ $discussion->sticky = Request::bool('sticky', false);
+
+ if (Request::get('type_id')) {
+ $discussion->type_id = Request::get('type_id');
+ }
+
+ $topic = json_decode(Request::get('topic'), true);
+
+ if (empty($topic['topic_id'])) {
+ $newTopic = ForumTopic::create([
+ 'range_id' => $this->course_id,
+ 'name' => $topic['name']
+ ]);
+
+ $topic['topic_id'] = $newTopic->topic_id;
+ }
+
+ $discussion->topic_id = $topic['topic_id'];
+ $discussion->store();
+
+ if (!$discussion_id && Request::get('content')) {
+ ForumPosting::create([
+ 'range_id' => $this->course_id,
+ 'discussion_id' => $discussion->discussion_id,
+ 'content' => Markup::markAsHtml(Request::get('content')),
+ 'user_id' => User::findCurrent()->user_id
+ ]);
+ } else {
+ TagRelation::deleteBySQL("range_id = ? AND range_type = 'forum'", [$discussion->discussion_id]);
+ }
+
+ $tags = json_decode(Request::get('tags'), true);
+
+ foreach ($tags as $tag) {
+ if (empty($tag['tag_id'])) {
+ $newTag = Tag::create([
+ 'name' => $tag['name'],
+ ]);
+
+ $tag['tag_id'] = $newTag->id;
+ }
+
+ TagRelation::create([
+ 'tag_id' => $tag['tag_id'],
+ 'range_id' => $discussion->discussion_id,
+ 'range_type' => 'forum'
+ ]);
+ }
+
+ PageLayout::postSuccess(_('Der Beitrag wurde gespeichert.'));
+
+ $this->relocate(
+ $discussion_id ? 'course/forum/topics/show/' . $discussion->topic_id : 'course/forum/discussions/show/' . $discussion->discussion_id
+ );
+ }
+
+ public function delete_action($discussion_id)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ $discussion = ForumDiscussion::find($discussion_id);
+
+ if (!$discussion) {
+ throw new AccessDeniedException();
+ }
+
+ TagRelation::deleteBySQL("range_id = ? AND range_type = 'forum'", [$discussion->discussion_id]);
+ $topic_id = $discussion->topic_id;
+
+ $discussion->delete();
+
+ PageLayout::postSuccess(_('Die Diskussion wurde gelöscht.'));
+
+ $this->relocate('course/forum/topics/show/' . $topic_id);
+ }
+}
diff --git a/app/controllers/course/forum/forum_controller.php b/app/controllers/course/forum/forum_controller.php
deleted file mode 100644
index 65eec63..0000000
--- a/app/controllers/course/forum/forum_controller.php
+++ /dev/null
@@ -1,51 +0,0 @@
-<?php
-
-abstract class ForumController extends StudipController {
- protected $with_session = true;
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
- /* * * * * H E L P E R F U N C T I O N S * * * * */
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
- function getId()
- {
- return ForumHelpers::getSeminarId();
- }
-
- /**
- * Common code for all actions: set default layout and page title.
- *
- * @param type $action
- * @param type $args
- */
- function before_filter(&$action, &$args)
- {
- $this->validate_args($args, ['option', 'option']);
-
- parent::before_filter($action, $args);
-
- $this->flash = Trails\Flash::instance();
-
- // Set help keyword for Stud.IP's user-documentation and page title
- PageLayout::setHelpKeyword('Basis.Forum');
- PageLayout::setTitle(Context::getHeaderLine() .' - '. _('Forum'));
-
- // the default for displaying timestamps
- $this->time_format_string = "%a %d. %B %Y, %H:%M";
- $this->time_format_string_short = "%d.%m.%Y, %H:%M";
-
- //$this->getId() depends on Context::get()
- checkObject();
- ForumVisit::setVisit($this->getId());
- if (Request::int('page')) {
- ForumHelpers::setPage(Request::int('page'));
- }
-
- $this->seminar_id = $this->getId();
-
- $this->no_entries = false;
- $this->highlight = [];
- $this->highlight_topic = '';
- $this->edit_posting = '';
- $this->js = '';
- }
-}
diff --git a/app/controllers/course/forum/index.php b/app/controllers/course/forum/index.php
deleted file mode 100644
index a4f6fb1..0000000
--- a/app/controllers/course/forum/index.php
+++ /dev/null
@@ -1,829 +0,0 @@
-<?php
-
-/*
- * Copyright (C) 2011 - Till Glöggler <tgloeggl@uos.de>
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU General Public License as
- * published by the Free Software Foundation; either version 2 of
- * the License, or (at your option) any later version.
- */
-
-require_once 'forum_controller.php';
-
-class Course_Forum_IndexController extends ForumController
-{
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
- /* V I E W - A C T I O N S */
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
-
- /**
- * redirect to correct page (overview or newest entries),
- * depending on whether there are any entries.
- */
- function enter_seminar_action() {
- if (ForumPerm::has('fav_entry', $this->getId())
- && ForumVisit::getCount($this->getId(), ForumVisit::getVisit($this->getId())) > 0) {
- $this->redirect('course/forum/index/newest');
- } else {
- $this->redirect('course/forum/index/index');
- }
- }
-
- /**
- * the main action for the forum. May be called with a topic_id to be displayed
- * and optionally the page to display
- *
- * @param type $topic_id the topic to display, defaults to the main
- * view of the current seminar
- * @param type $page the page to be displayed (for thread-view)
- */
- function index_action($topic_id = null, $page = null)
- {
- $nav = Navigation::getItem('course/forum2');
- $nav->setImage(Icon::create('forum', 'info'));
- Navigation::activateItem('course/forum2/index');
-
- // check, if the root entry is present
- ForumEntry::checkRootEntry($this->getId());
-
- /* * * * * * * * * * * * * * * * * * *
- * V A R I A B L E N F U E L L E N *
- * * * * * * * * * * * * * * * * * * */
-
- $this->section = 'index';
-
- $this->topic_id = $topic_id ? $topic_id : $this->getId();
- $this->constraint = ForumEntry::getConstraints($this->topic_id);
-
- // check if there has been submitted an invalid id and use seminar_id in case
- if (!$this->constraint) {
- $this->topic_id = $this->getId();
- $this->constraint = ForumEntry::getConstraints($this->topic_id);
- }
-
- $this->highlight_topic = Request::option('highlight_topic', null);
-
- // set page to which we shall jump
- if ($page) {
- ForumHelpers::setPage($page);
- }
-
- // we do not crawl deeper than level 2, we show a page chooser instead
- if ($this->constraint['depth'] > 2) {
- ForumHelpers::setPage(ForumEntry::getPostingPage($this->topic_id, $this->constraint));
-
- $path = ForumEntry::getPathToPosting($this->topic_id);
- array_shift($path);array_shift($path);$path_element = array_shift($path);
- $this->child_topic = $this->topic_id;
- $this->topic_id = $path_element['id'];
- $this->constraint = ForumEntry::getConstraints($this->topic_id);
- }
-
- // check if the topic_id matches the currently selected seminar
- ForumPerm::checkTopicId($this->getId(), $this->topic_id);
-
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
- * B E R E I C H E / T H R E A D S / P O S T I N G S L A D E N *
- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
-
- // load list of areas for use in thread-movement
- if (ForumPerm::has('move_thread', $this->getId())) {
- $this->areas = ForumEntry::getList('flat', $this->getId());
- }
-
- if ($this->constraint['depth'] > 1) { // POSTINGS
- $list = ForumEntry::getList('postings', $this->topic_id);
- if (!empty($list['list'])) {
- $this->postings = $list['list'];
- $this->number_of_entries = $list['count'];
- }
- } else {
- if ($this->constraint['depth'] == 0) { // BEREICHE
- $list = ForumEntry::getList('area', $this->topic_id);
- } else {
- $list = ForumEntry::getList('list', $this->topic_id);
- }
-
- if ($this->constraint['depth'] == 0) { // BEREICHE
- $new_list = [];
- $this->categories = [];
- // iterate over all categories and add the belonging areas to them
- foreach (ForumCat::getListWithAreas($this->getId(), false) as $category) {
- if ($category['topic_id']) {
- $new_list[$category['category_id']][$category['topic_id']] = $list['list'][$category['topic_id']];
- unset($list['list'][$category['topic_id']]);
- } else if (ForumPerm::has('add_area', $this->seminar_id)) {
- $new_list[$category['category_id']] = [];
- }
- $this->categories[$category['category_id']] = $category['entry_name'];
- }
-
- if (!empty($list['list'])) {
- // append the remaining entries to the standard category
- if (!isset($new_list[$this->getId()])) {
- $new_list[$this->getId()] = [];
- }
- $new_list[$this->getId()] = array_merge((array)$new_list[$this->getId()], $list['list']);
- }
-
- // check, if there are any orphaned entries
- foreach ($new_list as $key1 => $list_item) {
- foreach ($list_item as $key2 => $contents) {
- if (empty($contents)) {
- // remove the orphaned entry from the list and from the database
- unset($new_list[$key1][$key2]);
- ForumCat::removeArea($key2);
- }
- }
- }
-
- $this->list = $new_list;
-
- } else if ($this->constraint['depth'] == 1) { // THREADS
- if (!empty($list['list'])) {
- $this->list = [$list['list']];
- }
- }
- $this->number_of_entries = $list['count'];
- }
-
- // set the visit-date and get the stored last_visitdate
- $this->visitdate = ForumVisit::getLastVisit($this->getId());
-
- $this->seminar_id = $this->getId();
-
- // highlight text if passed some words to highlight
- if (Request::getArray('highlight')) {
- $this->highlight = Request::optionArray('highlight');
- }
-
- if (($this->edit_posting = Request::get('edit_posting', null))
- && !ForumPerm::hasEditPerms($this->edit_posting)) {
- $this->edit_posting = null;
- }
-
- // trigger a javascript action, like creating an answer or citing a thread
- if (Request::submitted('answer')) {
- $this->js = 'answer';
- } else if (Request::option('cite')) {
- $this->js = 'cite';
- $this->cite_id = $topic_id;
- }
-
- }
-
- /**
- * show newest entries
- *
- * @param int $page show entries on submitted page
- */
- function newest_action($page = null)
- {
- ForumPerm::check('fav_entry', $this->getId());
-
- $nav = Navigation::getItem('course/forum2');
- $nav->setImage(Icon::create('forum', 'info'));
- Navigation::activateItem('course/forum2/newest');
-
- // set page to which we shall jump
- if ($page) {
- ForumHelpers::setPage($page);
- }
-
- $this->section = 'newest';
- $this->topic_id = $this->getId();
-
- // set the visitdate of the seminar as the last visitdate
- $this->visitdate = ForumVisit::getLastVisit($this->getId());
-
- $list = ForumEntry::getList('newest', $this->topic_id);
- $this->postings = $list['list'] ?? [];
- $this->number_of_entries = $list['count'] ?? 0;
- $this->show_full_path = true;
-
- if (empty($this->postings)) {
- $this->no_entries = true;
- }
-
- $this->render_action('index');
- }
-
- /**
- * show all latest entries as flat list
- *
- * @param int $page show entries on submitted page
- */
- function latest_action($page = null)
- {
- ForumPerm::check('fav_entry', $this->getId());
-
- $nav = Navigation::getItem('course/forum2');
- $nav->setImage(Icon::create('forum', 'info'));
- Navigation::activateItem('course/forum2/latest');
-
- // set page to which we shall jump
- if ($page) {
- ForumHelpers::setPage($page);
- }
-
- $this->section = 'latest';
- $this->topic_id = $this->getId();
-
- // set the visitdate of the seminar as the last visitdate
- $this->visitdate = ForumVisit::getLastVisit($this->getId());
-
- $list = ForumEntry::getList('latest', $this->topic_id);
- $this->postings = $list['list'];
- $this->number_of_entries = $list['count'];
- $this->show_full_path = true;
- $this->no_entries = empty($this->postings);
-
- $this->render_action('index');
- }
-
- /**
- * show the current users favorized entries
- *
- * @param int $page show entries on submitted page
- */
- function favorites_action($page = null)
- {
- ForumPerm::check('fav_entry', $this->getId());
-
- $nav = Navigation::getItem('course/forum2');
- $nav->setImage(Icon::create('forum', 'info'));
- Navigation::activateItem('course/forum2/favorites');
-
- // set page to which we shall jump
- if ($page) {
- ForumHelpers::setPage($page);
- }
-
- $this->section = 'favorites';
- $this->topic_id = $this->getId();
-
- $list = ForumEntry::getList('favorites', $this->topic_id);
- $this->postings = $list['list'];
- $this->number_of_entries = $list['count'];
- $this->show_full_path = true;
- $this->no_entries = empty($this->postings);
-
- // exploit the visitdate for this view
- $this->visitdate = ForumVisit::getLastVisit($this->getId());
-
- $this->render_action('index');
- }
-
- /**
- * show search results
- *
- * @param int $page show entries on submitted page
- */
- function search_action($page = null)
- {
- if (Request::submitted('reset-search')) {
- $this->redirect('course/forum/index/index');
- return;
- }
-
- ForumPerm::check('search', $this->getId());
-
- $nav = Navigation::getItem('course/forum2');
- $nav->setImage(Icon::create('forum', 'info'));
- Navigation::activateItem('course/forum2/index');
-
- // set page to which we shall jump
- if ($page) {
- ForumHelpers::setPage($page);
- }
-
- $this->section = 'search';
- $this->topic_id = $this->getId();
- $this->show_full_path = true;
- $this->options = [];
- // parse filter-options
- foreach (['search_title', 'search_content', 'search_author'] as $option) {
- $this->options[$option] = Request::option($option);
- }
-
- $this->searchfor = Request::get('searchfor');
- if (mb_strlen($this->searchfor) < 3) {
- $this->flash['messages'] = ['error' => _('Ihr Suchbegriff muss mindestens 3 Zeichen lang sein und darf nur Buchstaben und Zahlen enthalten!')];
- } else {
- // get search-results
- $list = ForumEntry::getSearchResults($this->getId(), $this->searchfor, $this->options);
-
- $this->postings = $list['list'];
- $this->number_of_entries = $list['count'] ?? 0;
- $this->highlight = $list['highlight'] ?? false;
-
- if (empty($this->postings)) {
- $this->flash['messages'] = ['info' => _('Es wurden keine Beiträge gefunden, die zu Ihren Suchkriterien passen!')];
- }
- }
-
- // exploit the visitdate for this view
- $this->visitdate = ForumVisit::getLastVisit($this->getId());
-
- $this->render_action('index');
- }
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
- /* * * * P O S T I N G - A C T I O N S * * * */
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
-
- /**
- * Add a new entry. Has a simple spambot protection and checks
- * the parent_id to add the entry to, throwing an exception if missing.
- *
- * @throws Exception
- */
- function add_entry_action()
- {
- CSRFProtection::verifyUnsafeRequest();
- // Schutz vor Spambots - diese füllen meistens alle Felder aus, auch "versteckte".
- // Ist dieses Feld gefüllt, war das vermutlich kein Mensch
- if (Request::get('nixda')) {
- throw new Exception('Access denied!');
- }
-
- if (!$parent_id = Request::option('parent')) {
- throw new Exception('missing seminar_id/topic_id while adding a new entry!');
- }
-
- if ($this->seminar_id == $parent_id) {
- ForumPerm::check('add_area', $this->getId(), $parent_id);
- } else {
- ForumPerm::check('add_entry', $this->getId(), $parent_id);
- }
-
- $constraints = ForumEntry::getConstraints($parent_id);
-
- // if we are answering/citing a posting, we want to add it to the thread
- // (which is the parent of passed posting id)
- if ($constraints['depth'] == 3) {
- $parent_id = ForumEntry::getParentTopicId($parent_id);
- }
-
- $new_id = md5(uniqid(rand()));
-
- if ($GLOBALS['user']->id == 'nobody') {
- $fullname = Request::get('author', 'unbekannt');
- } else {
- $fullname = get_fullname($GLOBALS['user']->id);
- }
-
- ForumEntry::insert([
- 'topic_id' => $new_id,
- 'seminar_id' => $this->getId(),
- 'user_id' => $GLOBALS['user']->id,
- 'name' => Request::get('name') ?: '',
- 'content' => Studip\Markup::purifyHtml(Request::get('content')),
- 'author' => $fullname,
- 'author_host' => ($GLOBALS['user']->id == 'nobody') ? getenv('REMOTE_ADDR') : '',
- 'anonymous' => Config::get()->FORUM_ANONYMOUS_POSTINGS ? Request::get('anonymous') ? : 0 : 0
- ], $parent_id);
-
- $this->flash['notify'] = $new_id;
-
- $this->redirect('course/forum/index/index/' . $new_id .'#'. $new_id);
- }
-
- /**
- * Delete the submitted entry.
- *
- * @param string $topic_id the entry to delete
- */
- function delete_entry_action($topic_id)
- {
- // get the page of the posting to be able to jump there again
- $page = ForumEntry::getPostingPage($topic_id);
- URLHelper::addLinkParam('page', $page);
- $parent = null;
- if (
- ForumPerm::hasEditPerms($topic_id)
- || ForumPerm::check('remove_entry', $this->getId(), $topic_id)
- ) {
- CSRFProtection::verifyUnsafeRequest();
- $path = ForumEntry::getPathToPosting($topic_id);
- $topic = array_pop($path);
- $parent = array_pop($path);
-
- if ($topic_id != $this->getId()) {
- ForumEntry::delete($topic_id);
- PageLayout::postSuccess(sprintf(_('Der Eintrag %s wurde gelöscht!'), htmlReady($topic['name'])));
- } else {
- PageLayout::postWarning(_('Sie können nicht die gesamte Veranstaltung löschen!'));
- }
- }
-
- $this->redirect('course/forum/index/index/' . $parent['id'] .'/'. $page);
- }
-
- /**
- * Update the submitted entry.
- *
- * @param string $topic_id id of the entry to update
- * @throws AccessDeniedException
- */
- function update_entry_action($topic_id)
- {
- CSRFProtection::verifyUnsafeRequest();
-
- $name = Request::get('name', _('Kein Titel'));
- $content = Studip\Markup::purifyHtml(Request::get('content', _('Keine Beschreibung')));
-
- ForumPerm::check('add_entry', $this->getId(), $topic_id);
-
- if (ForumPerm::hasEditPerms($topic_id)) {
- ForumEntry::update($topic_id, $name, $content);
- } else {
- throw new AccessDeniedException(_('Sie haben keine Berechtigung, diesen Eintrag zu editieren!'));
- }
-
- if (Request::isXhr()) {
- $this->render_text(json_encode([
- 'name' => htmlReady($name),
- 'content' => formatReady($content)
- ]));
- } else {
- $this->redirect('course/forum/index/index/' . $topic_id .'#'. $topic_id);
- }
- }
-
- /**
- * Move the submitted thread to the submitted parent
- *
- * @param string $thread_id the thread to move
- * @param string $destination the threads new parent
- */
- function move_thread_action($thread_id, $destination) {
- ForumPerm::check('move_thread', $this->getId(), $thread_id);
- ForumPerm::check('move_thread', $this->getId(), $destination);
-
- $current_area = ForumEntry::getParentTopicId($thread_id);
-
- ForumEntry::move($thread_id, $destination);
-
- $this->redirect('course/forum/index/index/' . $current_area .'/'. ForumHelpers::getPage());
- }
-
- /**
- * Mark the submitted entry as favorite
- *
- * @param string $topic_id the entry to mark
- */
- function set_favorite_action($topic_id)
- {
- ForumPerm::check('fav_entry', $this->getId(), $topic_id);
-
- ForumFavorite::set($topic_id);
-
- if (Request::isXhr()) {
- $this->topic_id = $topic_id;
- $this->favorite = true;
- $this->render_template('course/forum/index/_favorite');
- } else {
- $this->redirect('course/forum/index/index/' . $topic_id .'#'. $topic_id);
- }
- }
-
- /**
- * Remove the submtted entry as favorite
- *
- * @param string $topic_id the entry to unmark
- */
- function unset_favorite_action($topic_id) {
- ForumPerm::check('fav_entry', $this->getId(), $topic_id);
-
- ForumFavorite::remove($topic_id);
-
- if (Request::isXhr()) {
- $this->topic_id = $topic_id;
- $this->favorite = false;
- $this->render_template('course/forum/index/_favorite');
- } else {
- $this->redirect('course/forum/index/index/' . $topic_id .'#'. $topic_id);
- }
- }
-
- /**
- * Jump to page in the entries of the submitted parent-entry
- * denoted by the submitted context (section)
- *
- * @param string $topic_id the parent-topic to goto
- * @param string $section the type of view (one of index/search)
- * @param int $page the page to jump to
- */
- function goto_page_action($topic_id, $section, $page)
- {
- switch ($section) {
- case 'index':
- $this->redirect('course/forum/index/index/' . $topic_id .'/'. (int)$page .'#'. $topic_id);
- break;
-
- case 'search':
- $optionlist = [];
-
- foreach (['search_title', 'search_content', 'search_author'] as $option) {
- if (Request::option($option)) {
- $optionlist[] = $option .'='. 1;
- }
- }
-
- $this->redirect('course/forum/index/'. $section .'/'. (int)$page
- .'/?searchfor='. Request::get('searchfor') .'&'. implode('&', $optionlist));
- break;
-
- default:
- $this->redirect('course/forum/index/'. $section .'/'. (int)$page);
- break;
- }
- }
-
- /**
- * Like the submitted topic
- *
- * @param string $topic_id the topic to like
- */
- function like_action($topic_id)
- {
- if (!Request::isPost()) {
- throw new MethodNotAllowedException();
- }
-
- ForumPerm::check('like_entry', $this->getId(), $topic_id);
-
- ForumLike::like($topic_id);
-
- if (Request::isXhr()) {
- $this->topic_id = $topic_id;
- $this->render_template('course/forum/index/_like');
- } else {
- $this->redirect('course/forum/index/index/' . $topic_id .'#'. $topic_id);
- }
- }
-
- /**
- * Remove like for the submitted topic
- *
- * @param string $topic_id the topic to unlike
- */
- function dislike_action($topic_id)
- {
- if (!Request::isPost()) {
- throw new MethodNotAllowedException();
- }
-
- ForumPerm::check('like_entry', $this->getId(), $topic_id);
-
- ForumLike::dislike($topic_id);
-
- if (Request::isXhr()) {
- $this->topic_id = $topic_id;
- $this->render_template('course/forum/index/_like');
- } else {
- $this->redirect('course/forum/index/index/' . $topic_id .'#'. $topic_id);
- }
- }
-
- /**
- * This action is used to close a thread.
- *
- * @param string $topic_id the topic which will be closed
- * @param string $redirect the topic which will be shown after closing the thread
- * @param int $page the page number of the topic $redirect
- */
- function close_thread_action($topic_id, $redirect, $page = 0)
- {
- ForumPerm::check('close_thread', $this->getId(), $topic_id);
-
- ForumEntry::close($topic_id);
-
- $success_text = _('Das Thema wurde erfolgreich geschlossen.');
-
- if (Request::isXhr()) {
- $this->render_text(MessageBox::success($success_text));
- } else {
- PageLayout::postSuccess($success_text);
- $this->redirect('course/forum/index/index/' . $redirect . '/' . $page);
- }
- }
-
- /**
- * This action is used to open a thread.
- *
- * @param string $topic_id the topic which will be opened
- * @param string $redirect the topic which will be shown after opening the thread
- * @param int $page the page number of the topic $redirect
- */
- function open_thread_action($topic_id, $redirect, $page = 0)
- {
- ForumPerm::check('close_thread', $this->getId(), $topic_id);
-
- ForumEntry::open($topic_id);
-
- $success_text = _('Das Thema wurde erfolgreich geöffnet.');
-
- if (Request::isXhr()) {
- $this->render_text(MessageBox::success($success_text));
- } else {
- PageLayout::postSuccess($success_text);
- $this->redirect('course/forum/index/index/' . $redirect . '/' . $page);
- }
- }
-
- /**
- * This action is used to mark a thread as sticky.
- *
- * @param string $topic_id the topic which will be marked as sticky.
- * @param string $redirect the topic which will be shown afterwards
- * @param int $page the page number of the topic $redirect
- */
- function make_sticky_action($topic_id, $redirect, $page = 0)
- {
- ForumPerm::check('make_sticky', $this->getId(), $topic_id);
-
- ForumEntry::sticky($topic_id);
-
- $success_text = _('Das Thema wurde erfolgreich in der Themenliste hervorgehoben.');
-
- if (Request::isXhr()) {
- $this->render_text(MessageBox::success($success_text));
- } else {
- $this->flash['messages'] = ['success' => $success_text];
- $this->redirect('course/forum/index/index/' . $redirect . '/' . $page);
- }
- }
-
- /**
- * This action is used to remove the sticky attribute from a topic.
- *
- * @param string $topic_id the topic which will be marked as unsticky.
- * @param string $redirect the topic which will be shown afterwards
- * @param int $page the page number of the topic $redirect
- */
- function make_unsticky_action($topic_id, $redirect, $page = 0)
- {
- ForumPerm::check('make_sticky', $this->getId(), $topic_id);
-
- ForumEntry::unsticky($topic_id);
-
- $success_text = _('Die Hervorhebung des Themas in der Themenliste wurde entfernt.');
-
- if (Request::isXhr()) {
- $this->render_text(MessageBox::success($success_text));
- } else {
- $this->flash['messages'] = ['success' => $success_text];
- $this->redirect('course/forum/index/index/' . $redirect . '/' . $page);
- }
- }
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
- /* * * * C O N F I G - A C T I O N S * * * */
- /* * * * * * * * * * * * * * * * * * * * * * * * * */
-
- /**
- * Add submitted category to current course
- */
- function add_category_action()
- {
- CSRFProtection::verifyUnsafeRequest();
-
- ForumPerm::check('add_category', $this->getId());
-
- $category_id = ForumCat::add($this->getId(), Request::get('category'));
-
- ForumPerm::checkCategoryId($this->getId(), $category_id);
-
- $this->redirect('course/forum/index#cat_'. $category_id);
- }
-
- /*
- * Remove submitted category from current course
- */
- function remove_category_action($category_id)
- {
- CSRFProtection::verifyUnsafeRequest();
-
- ForumPerm::checkCategoryId($this->getId(), $category_id);
- ForumPerm::check('remove_category', $this->getId());
-
- $this->flash['messages'] = ['success' => _('Die Kategorie wurde gelöscht!')];
- ForumCat::remove($category_id, $this->getId());
-
- if (Request::isXhr()) {
- $this->render_template('course/forum/messages');
- } else {
- $this->redirect('course/forum/index/index');
- }
-
- }
-
- /**
- * Change the name of the submitted category
- *
- * @param string $category_id the category to edit
- */
- function edit_category_action($category_id) {
- CSRFProtection::verifyUnsafeRequest();
-
- ForumPerm::checkCategoryId($this->getId(), $category_id);
- ForumPerm::check('edit_category', $this->getId());
-
- if (Request::isXhr()) {
- ForumCat::setName($category_id, Request::get('name'));
- $this->render_nothing();
- } else {
- ForumCat::setName($category_id, Request::get('name'));
- $this->flash['messages'] = ['success' => _('Der Name der Kategorie wurde geändert.')];
- $this->redirect('course/forum/index/index#cat_' . $category_id);
- }
-
- }
-
- /**
- * Save the ordering of the categories
- */
- function savecats_action()
- {
- ForumPerm::check('sort_category', $this->getId());
-
- $pos = 0;
- foreach (Request::getArray('categories') as $category_id) {
- ForumPerm::checkCategoryId($this->getId(), $category_id);
- ForumCat::setPosition($category_id, $pos);
- $pos++;
- }
-
- $this->render_nothing();
- }
-
- /*
- * Subscribe to the submitted topic and receive mails on new postings
- *
- * @param string $topic_id
- */
- function abo_action($topic_id)
- {
- ForumPerm::check('abo', $this->getId(), $topic_id);
-
- ForumAbo::add($topic_id);
- $this->constraint = ForumEntry::getConstraints($topic_id);
-
- if (Request::isXhr()) {
- $this->render_template('course/forum/index/_abo_link');
- } else {
- switch ($this->constraint['depth']) {
- case 0: $msg = _('Sie haben das gesamte Forum abonniert!');break;
- case 1: $msg = _('Sie haben diesen Bereich abonniert.');break;
- default: $msg = _('Sie haben dieses Thema abonniert');break;
- }
- $this->flash['messages'] = ['success' => $msg .' '. _('Sie werden nun über jeden neuen Beitrag informiert.')];
- $this->redirect('course/forum/index/index/' . $topic_id);
- }
- }
-
- /**
- * Unsubscribe from the passed topic
- *
- * @param string $topic_id
- */
- function remove_abo_action($topic_id)
- {
- ForumPerm::check('abo', $this->getId(), $topic_id);
-
- ForumAbo::delete($topic_id);
-
- if (Request::isXhr()) {
- $this->constraint = ForumEntry::getConstraints($topic_id);
- $this->render_template('course/forum/index/_abo_link');
- } else {
- $this->flash['messages'] = ['success' => _('Abonnement aufgehoben.')];
- $this->redirect('course/forum/index/index/' . $topic_id);
- }
- }
-
- /**
- * Generate a pdf-export for the whole forum or the passed subtree
- *
- * @param string $parent_id
- */
- function pdfexport_action($parent_id = null)
- {
- ForumPerm::check('pdfexport', $this->getId(), $parent_id);
-
- ForumHelpers::createPDF($this->getId(), $parent_id);
- }
-
- public function rescue($exception)
- {
- if ($exception instanceof AccessDeniedException) {
- throw new LoginException();
- }
-
- return parent::rescue($exception);
- }
-}
diff --git a/app/controllers/course/forum/recent.php b/app/controllers/course/forum/recent.php
new file mode 100644
index 0000000..2c3fda2
--- /dev/null
+++ b/app/controllers/course/forum/recent.php
@@ -0,0 +1,24 @@
+<?php
+require_once 'ForumBaseController.php';
+
+class Course_Forum_RecentController extends Forum\ForumBaseController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ Navigation::activateItem('course/forum/topics');
+ }
+
+ public function index_action()
+ {
+ PageLayout::setTitle(_('Neueste Beiträge'));
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/recent/Index')
+ ->withProps([
+ 'last_visit' => Request::int('last_visit')
+ ])
+ );
+ }
+}
diff --git a/app/controllers/course/forum/search.php b/app/controllers/course/forum/search.php
new file mode 100644
index 0000000..84172fb
--- /dev/null
+++ b/app/controllers/course/forum/search.php
@@ -0,0 +1,217 @@
+<?php
+require_once 'ForumBaseController.php';
+
+use Forum\ForumDiscussion;
+use Forum\ForumDiscussionType;
+use Forum\DTO\ForumMember;
+use Forum\DTO\ForumTag;
+
+class Course_Forum_SearchController extends Forum\ForumBaseController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ Navigation::activateItem('course/forum');
+ }
+
+ public function index_action()
+ {
+ $topics = DBManager::get()->fetchAll(
+ "SELECT
+ `ft`.`topic_id`, `ft`.`name`, `fc`.`color`
+ FROM `forum_topics` AS `ft`
+ LEFT JOIN `forum_categories` AS `fc` USING (`category_id`)
+ WHERE `ft`.`range_id` = :course_id
+ ORDER BY `ft`.`position` ASC, `ft`.`mkdate` DESC
+ ",
+ ['course_id' => $this->course_id]
+ );
+
+ $course_members = [];
+ foreach (Context::get()->members as $member) {
+ $course_members[] = [
+ 'user_id' => $member['user_id'],
+ 'name' => $member['Vorname'] . ' ' . $member['Nachname'],
+ 'avatar_url' => Avatar::getAvatar($member['user_id'])->getURL(Avatar::NORMAL),
+ 'profile_url' => URLHelper::getLink('dispatch.php/profile', ['username' => $member['username']], true)
+ ];
+ }
+
+ $search_object = $this->buildSearchObject();
+ $all_tags = array_map(fn(ForumTag $tag) => $tag->toRawArray(), ForumTag::getForumTags());
+ $discussion_types = array_map(fn(ForumDiscussionType $discussion_type) => $discussion_type->toRawArray(), ForumDiscussionType::getForumDiscussionType());
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/search/Index')
+ ->withProps([
+ 'search' => $search_object,
+ 'discussions' => $this->getResult($search_object),
+ 'topics' => $topics,
+ 'discussion_types' => $discussion_types,
+ 'tags' => $all_tags,
+ 'course_members' => $course_members,
+ ])
+ );
+ }
+
+ private function getResult($search_object): array
+ {
+ if ($this->isSearchObjectEmpty($search_object)) {
+ unset($_SESSION['forum'][$this->course_id]['search']);
+ return [];
+ }
+
+ $query = [
+ "SELECT
+ discussions.discussion_id,
+ COUNT(DISTINCT postings.posting_id) AS 'postings_count'
+ FROM `forum_discussions` AS `discussions`
+ LEFT JOIN `forum_postings` AS `postings` USING(`discussion_id`)
+ LEFT JOIN `tags_relations` ON (`tags_relations`.`range_id` = `discussions`.`discussion_id` AND `range_type` = 'forum')
+ WHERE `postings`.`range_id` = :course_id ",
+ [
+ 'course_id' => $this->course_id
+ ]
+ ];
+
+ $keyword = $search_object['keyword'];
+ if ($keyword) {
+ $query[0] .= " AND (discussions.title LIKE :keyword OR postings.content LIKE :keyword)";
+ $query[1]["keyword"] = "%$keyword%";
+ }
+
+ if ($search_object['begin']) {
+ $query[0] .= " AND postings.mkdate >= :begin";
+ $query[1]['begin'] = $search_object['begin'];
+ }
+
+ if ($search_object['end']) {
+ $query[0] .= " AND postings.mkdate <= :end";
+ $query[1]['end'] = $search_object['end'];
+ }
+
+ if ($search_object['topic_ids']) {
+ $query[0] .= " AND discussions.topic_id IN (:topic_ids)";
+ $query[1]['topic_ids'] = $search_object['topic_ids'];
+ }
+
+ if ($search_object['discussion_type_ids']) {
+ $query[0] .= " AND discussions.type_id IN (:type_ids)";
+ $query[1]['type_ids'] = $search_object['discussion_type_ids'];
+ }
+
+ if ($search_object['tag_ids']) {
+ $query[0] .= " AND tags_relations.tag_id IN (:tag_ids)";
+ $query[1]['tag_ids'] = $search_object['tag_ids'];
+ }
+
+ if ($search_object['user_ids']) {
+ $query[0] .= " AND postings.user_id IN (:user_ids)";
+ $query[1]['user_ids'] = $search_object['user_ids'];
+ }
+
+ $query[0] .= match ($search_object['discussion_status']) {
+ 2 => " AND discussions.closed_at IS NULL", // opens
+ 3 => " AND discussions.closed_at IS NOT NULL", // closed
+ default => ""
+ };
+
+ $result = DBManager::get()->fetchAll(
+ $query[0]." GROUP BY discussions.discussion_id",
+ $query[1]
+ );
+
+ $discussions = ForumDiscussion::findBySQL("discussion_id IN (:discussion_ids)", ['discussion_ids' => array_column($result, 'discussion_id')]);
+
+
+ return array_map(function (ForumDiscussion $discussion) use ($result) {
+ $postings_count = array_find($result, fn($item) => $item['discussion_id'] === $discussion->discussion_id)['postings_count'];
+ $members = array_map(fn(ForumMember $member) => $member->toRawArray(), $discussion->members);
+ $tags = array_map(fn(ForumTag $tag) => $tag->toRawArray(), $discussion->tags);
+
+ return [
+ 'id' => $discussion->discussion_id,
+ 'title' => $discussion->title,
+ 'closed_at' => $discussion->closed_at ? date('c', $discussion->closed_at) : null,
+ 'view_count' => (int) $discussion->view_count,
+ 'sticky' => (bool) $discussion->sticky,
+ 'mkdate' => date('c', $discussion->mkdate),
+ 'chdate' => date('c', $discussion->chdate),
+ 'topic' => $discussion->topic->toRawArray(),
+ 'category' => $discussion->category ? [
+ 'name' => $discussion->category->name,
+ 'color' => $discussion->category->color,
+ ] : [],
+ 'discussion_type' => $discussion->discussion_type ? [
+ 'name' => $discussion->discussion_type->name,
+ 'icon' => $discussion->discussion_type->icon,
+ ] : [],
+ 'members' => $members,
+ 'tags' => $tags,
+ 'meta' => [
+ 'postings_count' => (int) $postings_count,
+ 'recent_activity' => $discussion->metadata['recent_activity'] ? date('c', $discussion->metadata['recent_activity']) : null,
+ ]
+ ];
+ }, $discussions);
+ }
+
+ private function isSearchObjectEmpty($search_object): bool {
+ if (
+ $search_object['keyword'] ||
+ $search_object['begin'] ||
+ $search_object['end'] ||
+ $search_object['discussion_status'] ||
+ $search_object['discussion_type_ids'] ||
+ $search_object['tag_ids'] ||
+ $search_object['topic_ids'] ||
+ $search_object['user_ids']
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private function buildSearchObject(): array
+ {
+ $request = Request::getInstance();
+ if (
+ $request->offsetExists('keyword') ||
+ $request->offsetExists('begin') ||
+ $request->offsetExists('end') ||
+ $request->offsetExists('discussion_status') ||
+ $request->offsetExists('discussion_type_ids') ||
+ $request->offsetExists('tag_ids') ||
+ $request->offsetExists('topic_ids') ||
+ $request->offsetExists('user_ids')
+ ) {
+ $search_object = [
+ 'keyword' => Request::get('keyword'),
+ 'begin' => Request::int('begin'),
+ 'end' => Request::int('end'),
+ 'discussion_status' => Request::int('discussion_status'),
+ 'discussion_type_ids' => Request::getArray('discussion_type_ids'),
+ 'tag_ids' => Request::getArray('tag_ids'),
+ 'topic_ids' => Request::getArray('topic_ids'),
+ 'user_ids' => Request::getArray('user_ids')
+ ];
+
+ $_SESSION['forum'][$this->course_id]['search'] = $search_object;
+ return $search_object;
+ }
+
+ $session_search = $_SESSION['forum'][$this->course_id]['search'] ?? [];
+ return [
+ 'keyword' => $session_search['keyword'] ?? '',
+ 'begin' => $session_search['begin'] ?? 0,
+ 'end' => $session_search['end'] ?? 0,
+ 'discussion_status' => $session_search['discussion_status'] ?? 0,
+ 'discussion_type_ids' => $session_search['discussion_type_ids'] ?? [],
+ 'tag_ids' => $session_search['tag_ids'] ?? [],
+ 'topic_ids' => $session_search['topic_ids'] ?? [],
+ 'user_ids' => $session_search['user_ids'] ?? []
+ ];
+ }
+}
diff --git a/app/controllers/course/forum/subscriptions.php b/app/controllers/course/forum/subscriptions.php
new file mode 100644
index 0000000..399c072
--- /dev/null
+++ b/app/controllers/course/forum/subscriptions.php
@@ -0,0 +1,19 @@
+<?php
+require_once 'ForumBaseController.php';
+
+class Course_Forum_SubscriptionsController extends Forum\ForumBaseController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ Navigation::activateItem('course/forum/subscriptions');
+ }
+
+ public function index_action()
+ {
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/subscriptions/Index')
+ );
+ }
+}
diff --git a/app/controllers/course/forum/topics.php b/app/controllers/course/forum/topics.php
new file mode 100644
index 0000000..0070937
--- /dev/null
+++ b/app/controllers/course/forum/topics.php
@@ -0,0 +1,157 @@
+<?php
+require_once 'ForumBaseController.php';
+
+use Forum\ForumCategory;
+use Forum\ForumSubscription;
+use Forum\ForumTopic;
+
+class Course_Forum_TopicsController extends Forum\ForumBaseController
+{
+ public function before_filter(&$action, &$args)
+ {
+ parent::before_filter($action, $args);
+
+ unset($_SESSION['forum'][$this->course_id]['search']);
+
+ Navigation::activateItem('course/forum/topics');
+ }
+
+ public function index_action()
+ {
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/topics/Index')
+ );
+ }
+
+ public function show_action($topic_id)
+ {
+ $topic = ForumTopic::find($topic_id);
+
+ if (!$topic) {
+ throw new AccessDeniedException();
+ }
+
+ PageLayout::setTitle($topic->name);
+
+ $user_subscription = ForumSubscription::findOneBySQL(
+ "subject = :subject AND subject_id = :subject_id AND user_id = :user_id",
+ [
+ 'subject' => 'topic',
+ 'subject_id' => $topic->getId(),
+ 'user_id' => User::findCurrent()->user_id
+ ]
+ );
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/topics/Show')
+ ->withProps([
+ 'topic' => $topic->transformData(),
+ 'category' => $topic->category ? $topic->category->transformData() : [],
+ 'user_subscription' => $user_subscription ? $user_subscription->toRawArray() : [],
+ 'metadata' => [
+ 'postings_count' => (int) $topic->metadata['postings_count'],
+ 'users_count' => (int) $topic->metadata['users_count'],
+ 'recent_activity' => date('c', $topic->metadata['recent_activity'])
+ ]
+ ])
+ );
+ }
+
+ public function edit_action($topic_id = null)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ if ($topic_id) {
+ PageLayout::setTitle(_('Thema bearbeiten'));
+ $topic = ForumTopic::getCourseTopic($this->course_id, $topic_id);
+
+ if (!$topic) {
+ throw new AccessDeniedException();
+ }
+ } else {
+ PageLayout::setTitle(_('Neues Thema anlegen'));
+ $topic = new ForumTopic();
+ $topic['category_id'] = Request::get('category_id');
+ }
+
+ $categories = DBManager::get()->fetchAll(
+ "SELECT * FROM `forum_categories` WHERE `range_id` = ? ORDER BY `position` ASC, `mkdate` DESC",
+ [$this->course_id]
+ );
+
+ $this->render_vue_app(
+ Studip\VueApp::create('forum/topics/Edit')
+ ->withProps([
+ 'topic' => $topic->transformData(),
+ 'categories' => $categories
+ ])
+ );
+ }
+
+ public function save_action($topic_id = null)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ CSRFProtection::verifyUnsafeRequest();
+
+ if ($topic_id) {
+ $topic = ForumTopic::getCourseTopic($this->course_id, $topic_id);
+ if (!$topic) {
+ throw new AccessDeniedException();
+ }
+ } else {
+ $topic = new ForumTopic();
+ $topic->range_id = $this->course_id;
+ }
+
+ $category = json_decode(Request::get('category'), true);
+
+ if (empty($category['category_id']) && !empty($category['name'])) {
+ $newCategory = ForumCategory::create([
+ 'range_id' => $this->course_id,
+ 'color' => '#28497C',
+ 'name' => $category['name']
+ ]);
+
+ $category['category_id'] = $newCategory->category_id;
+ } else {
+ $topic->category_id = null;
+ }
+
+ if (!empty($category['category_id'])) {
+ $topic->category_id = $category['category_id'];
+ }
+
+ $topic->name = Request::get('name');
+ $topic->description = Request::get('description');
+
+ $topic->store();
+
+ PageLayout::postSuccess(_('Das Thema wurde gespeichert.'));
+
+ $this->relocate('course/forum/topics/show/' . $topic->topic_id);
+ }
+
+ public function delete_action($topic_id)
+ {
+ if (!$this->is_moderator) {
+ throw new AccessDeniedException();
+ }
+
+ $topic = ForumTopic::getCourseTopic($this->course_id, $topic_id);
+
+ if (!$topic) {
+ throw new AccessDeniedException();
+ }
+
+ $topic->delete();
+
+ PageLayout::postSuccess(_('Das Thema wurde gelöscht.'));
+
+ $this->relocate('course/forum/topics');
+ }
+}
diff --git a/app/controllers/course/topics.php b/app/controllers/course/topics.php
index 36e25ec..e955ba1 100644
--- a/app/controllers/course/topics.php
+++ b/app/controllers/course/topics.php
@@ -97,9 +97,6 @@ class Course_TopicsController extends AuthenticatedController
if (Request::bool('folder')) {
$topic->connectWithDocumentFolder();
}
-
- // create a connection to the module forum (can be anything)
- // will update title and description automagically
if (Request::bool('forumthread')) {
$topic->connectWithForumThread();
}
diff --git a/app/controllers/institute/basicdata.php b/app/controllers/institute/basicdata.php
index 3a5a751..c1bb1cd 100644
--- a/app/controllers/institute/basicdata.php
+++ b/app/controllers/institute/basicdata.php
@@ -432,14 +432,6 @@ class Institute_BasicdataController extends AuthenticatedController
}
}
- // delete all contents in forum-modules
- foreach (PluginEngine::getPlugins(ForumModule::class) as $plugin) {
- $plugin->deleteContents($i_id); // delete content irrespective of plugin-activation in the seminar
- if ($plugin->isActivated($i_id)) { // only show a message, if the plugin is activated, to not confuse the user
- $details[] = sprintf(_('Einträge in %s gelöscht.'), $plugin->getPluginName());
- }
- }
-
// Alle Pluginzuordnungen entfernen
PluginManager::getInstance()->deactivateAllPluginsForRange('inst', $i_id);
diff --git a/app/controllers/privacy.php b/app/controllers/privacy.php
index 5598f65..7aa8780 100644
--- a/app/controllers/privacy.php
+++ b/app/controllers/privacy.php
@@ -437,7 +437,7 @@ class PrivacyController extends AuthenticatedController
'description' => _('Nachrichten, Kommentare, Blubber, News'),
],
'content' => [
- 'icon' => Icon::create('forum2'),
+ 'icon' => Icon::create('forum'),
'title' => _('Inhalte'),
'description' => _('Courseware, Dateien, Forum, Wiki, Literaturlisten'),
],
diff --git a/app/views/course/forum/admin/childs.php b/app/views/course/forum/admin/childs.php
deleted file mode 100644
index c3a8a6b..0000000
--- a/app/views/course/forum/admin/childs.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<? foreach ($entries as $area): ?>
-<ul style="margin: 0;">
- <li data-id="<?= $area['topic_id'] ?>">
- <? if ($area['content_raw']) : ?>
- <a class="tooltip2">
- <?= Icon::create('info-circle', 'inactive')->asImg(['class' => 'text-top']) ?>
- <span><?= nl2br(htmlReady($area['content_raw'])) ?></span>
- </a>
- <? endif ?>
-
- <? if ($area['depth'] < 3) : ?>
- <a href="javascript:STUDIP.Forum.adminLoadChilds('<?= $area['topic_id'] ?>')"><?= htmlReady($area['name_raw']) ?></a>
- <? else : ?>
- <?= htmlReady($area['name_raw']) ?>
- <? endif ?>
-
- <a href="javascript:STUDIP.Forum.cut('<?= $area['topic_id'] ?>');" data-role="cut">
- <?= Icon::create('export') ?>
- </a>
-
-
- <a href="javascript:STUDIP.Forum.cancelCut('<?= $area['topic_id'] ?>');" data-role="cancel_cut" style="display: none">
- <?= Icon::create('export', Icon::ROLE_ATTENTION) ?>
- </a>
-
- <a href="javascript:STUDIP.Forum.paste('<?= $area['topic_id'] ?>');" data-role="paste" style="display: none">
- <?= Icon::create('arr_2left', Icon::ROLE_SORT) ?>
- </a>
- </li>
-</ul>
-<? endforeach ?>
diff --git a/app/views/course/forum/admin/index.php b/app/views/course/forum/admin/index.php
deleted file mode 100644
index 154e44c..0000000
--- a/app/views/course/forum/admin/index.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-Helpbar::get()->addPlainText(
- _('Bedienungshinweise'),
- _('Sie befinden sich hier in der Administrationsansicht des Forums. '
- . 'Mit den blauen Pfeilen können Sie einen oder mehrere Einträge auswählen, welche dann verschoben werden können. '),
- Icon::create('info', 'info_alt')
-);
-Helpbar::get()->addPlainText(
- '',
- _('Sie sollten nicht mehr als 20 Einträge gleichzeitig auswählen, da das verschieben sonst sehr lange dauern kann.')
-);
-?>
-<div id="forum">
- <ul style="margin: 0; padding-left: 20px;" class="js">
- <? foreach ($list as $category_id => $entries) : ?>
- <li data-id="<?= $category_id ?>">
- <a class="tooltip2"></a>
- <b><?= htmlReady($categories[$category_id]) ?></b>
- <a href="javascript:STUDIP.Forum.paste('<?= $category_id ?>');" data-role="paste" style="display: none">
- <?= Icon::create('arr_2left', 'sort')->asImg() ?>
- </a>
- <br>
-
- <?= $this->render_partial('course/forum/admin/childs', compact('entries')) ?>
- </li>
- <? endforeach ?>
- </ul>
-</div>
-<noscript>
- <?= MessageBox::error(_('Die Forenadministration funktioniert nur mit eingeschaltetem JavaScript!')) ?>
-</noscript>
diff --git a/app/views/course/forum/area/_add_area_form.php b/app/views/course/forum/area/_add_area_form.php
deleted file mode 100644
index 4dd6781..0000000
--- a/app/views/course/forum/area/_add_area_form.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<tr class="new_area">
- <td class="areaentry"></td>
- <td class="areaentry">
- <form class="add_area_form" method="post" action="<?= $controller->link_for('course/forum/area/add/' . $category_id) ?>" class="default">
- <?= CSRFProtection::tokenTag() ?>
- <input type="text" name="name" class="size-l no-hint" maxlength="255" placeholder="<?= _('Name des neuen Bereiches') ?>" required><br>
- <textarea name="content" class="size-l" style="height: 3em;" placeholder="<?= _('Optionale Beschreibung des neuen Bereiches') ?>"></textarea>
-
- <?= Studip\Button::create(_('Bereich hinzufügen')) ?>
- <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('course/forum/index/index#cat_'. $category_id)) ?>
- </form>
- </td>
- <td class="postings">0</td>
- <td class="answer" colspan="2"><br><?= _('keine Antworten') ?></td>
-</tr>
diff --git a/app/views/course/forum/area/_edit_area_form.php b/app/views/course/forum/area/_edit_area_form.php
deleted file mode 100644
index 9919d69..0000000
--- a/app/views/course/forum/area/_edit_area_form.php
+++ /dev/null
@@ -1,7 +0,0 @@
-<form method="post" action="<?= $controller->link_for('course/forum/area/edit/' . $entry['topic_id']) ?>" class="default">
- <input type="text" name="name" class="size-l no-hint" maxlength="255" value="<?= $entry['name_raw'] ?>" onClick="jQuery(this).focus()"><br>
- <textarea name="content" class="size-l" style="height: 3em;" onClick="jQuery(this).focus()"><?= $entry['content_raw'] ?></textarea>
-
- <?= Studip\Button::createAccept(_('Speichern')) ?>
- <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('course/forum/index')) ?>
-</form>
diff --git a/app/views/course/forum/area/_edit_category_form.php b/app/views/course/forum/area/_edit_category_form.php
deleted file mode 100644
index 054c8d6..0000000
--- a/app/views/course/forum/area/_edit_category_form.php
+++ /dev/null
@@ -1,8 +0,0 @@
-<form method="post" action="<?= $controller->link_for('course/forum/index/edit_category/' . $category_id) ?>" class="default">
- <input type="text" required name="name" class="size-m" maxlength="255" value="<?= htmlReady($categories[$category_id]) ?>">
-
- <?= Studip\Button::createAccept(_('Kategorie speichern'), '',
- ['onClick' => "javascript:STUDIP.Forum.saveCategoryName('". $category_id ."'); return false;"]) ?>
- <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->url_for('course/forum/index/index#cat_'. $category_id),
- ['onClick' => "STUDIP.Forum.cancelEditCategoryName('". $category_id ."'); return false;"]) ?>
-</form>
diff --git a/app/views/course/forum/area/_js_templates.php b/app/views/course/forum/area/_js_templates.php
deleted file mode 100644
index 975f42a..0000000
--- a/app/views/course/forum/area/_js_templates.php
+++ /dev/null
@@ -1,45 +0,0 @@
-<script type="text/template" class="edit_category">
-<span class="edit_category">
- <form class="default">
- <input type="text" required name="name" maxlength="255" class="size-m no-hint" value="<%- name %>">
-
- <?= ForumHelpers::replace(Studip\LinkButton::createAccept(_('Kategorie speichern'),
- "javascript:STUDIP.Forum.saveCategoryName('%%%- category_id ###');")) ?>
- <?= ForumHelpers::replace(Studip\LinkButton::createCancel(_('Abbrechen'),
- "javascript:STUDIP.Forum.cancelEditCategoryName('%%%- category_id ###')")) ?>
- </form>
-</span>
-</script>
-
-<script type="text/template" class="edit_area">
-<span class="edit_area">
- <form class="default">
- <input type="text" name="name" class="size-l no-hint" maxlength="255" value="<%- name %>" onClick="jQuery(this).focus()"><br>
- <textarea name="content" class="size-l" style="height: 3em;" onClick="jQuery(this).focus()"><%- content %></textarea>
-
- <?= ForumHelpers::replace(Studip\LinkButton::createAccept(_('Speichern'),
- "javascript:STUDIP.Forum.saveArea('%%%- area_id ###');")) ?>
- <?= ForumHelpers::replace(Studip\LinkButton::createCancel(_('Abbrechen'),
- "javascript:STUDIP.Forum.cancelEditArea('%%%- area_id ###');")) ?>
- </form>
-</span>
-</script>
-
-<script type="text/template" class="add_area">
-<tr class="new_area">
- <td class="areaentry"></td>
- <td class="areaentry">
- <form class="add_area_form default">
- <?= CSRFProtection::tokenTag() ?>
- <input type="hidden" name="category_id" value="<%- category_id %>">
- <input type="text" name="name" class="size-l no-hint" maxlength="255" placeholder="<?= _('Name des neuen Bereiches') ?>" required><br>
- <textarea name="content" class="size-l" style="height: 3em;" placeholder="<?= _('Optionale Beschreibung des neuen Bereiches') ?>"></textarea>
-
- <?= Studip\Button::create(_('Bereich hinzufügen')) ?>
- <?= Studip\LinkButton::createCancel(_('Abbrechen'), "javascript:STUDIP.Forum.cancelAddArea();") ?>
- </form>
- </td>
- <td class="postings">0</td>
- <td class="answer" colspan="2"><br><?= _('keine Antworten') ?></td>
-</tr>
-</script>
diff --git a/app/views/course/forum/area/add.php b/app/views/course/forum/area/add.php
deleted file mode 100644
index 1449a5d..0000000
--- a/app/views/course/forum/area/add.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<tr data-area-id="<?= $entry['topic_id'] ?>" <?= (ForumPerm::has('sort_area', $seminar_id)) ? 'class="movable"' : '' ?>>
- <td class="icon <?= ForumPerm::has('sort_area', $seminar_id) ? 'drag-handle' : '' ?>">
- <? if ($entry['chdate'] >= $visitdate && $entry['user_id'] !== $GLOBALS['user']->id): ?>
- <?= Icon::create('forum', Icon::ROLE_ATTENTION)->asImg([
- 'title' => _('Dieser Eintrag ist neu!'),
- ]) ?>
- <? else : ?>
- <? $num_postings = ForumVisit::getCount($entry['topic_id'], $visitdate) ?>
- <?= Icon::create('forum', $num_postings > 0 ? Icon::ROLE_ATTENTION : Icon::ROLE_INFO)->asImg([
- 'title' => ForumHelpers::getVisitText($num_postings, $entry['topic_id']),
- ]) ?>
- <? endif ?>
- </td>
- <td class="areaentry">
- <div style="position: relative;<?= Request::get('edit_area') == $entry['topic_id'] ? 'height: auto;' : '' ?>">
-
- <span class="areadata" <?= Request::get('edit_area') != $entry['topic_id'] ? '' : 'style="display: none;"' ?>>
- <a href="<?= $controller->link_for("course/forum/index/index/{$entry['topic_id']}#{$entry['topic_id']}") ?>">
- <span class="areaname"><?= htmlReady($entry['name_raw']) ?></span>
- </a>
- <div class="areacontent" data-content="<?= htmlReady($entry['content_raw']) ?>">
- <? $description = ForumEntry::killFormat(ForumEntry::killEdit($entry['content_raw'])) ?>
- <?= htmlReady(mila($description, 150)) ?>
- </div>
- </span>
-
-
- <? if (ForumPerm::has('edit_area', $seminar_id) && Request::get('edit_area') == $entry['topic_id']) : ?>
- <span style="text-align: center;">
- <div style="width: 90%">
- <?= $this->render_partial('course/forum/area/_edit_area_form', compact('entry')) ?>
- </div>
- </span>
- <? endif ?>
- </div>
- </td>
-
- <td class="postings">
- <?= number_format(max(($entry['num_postings'] ?? 0) - 1, 0), 0, ',', '.') ?>
- </td>
-
- <td class="answer hidden-tiny-down">
- <?= $this->render_partial('course/forum/index/_last_post.php', compact('entry')) ?>
- </td>
-
- <td class="actions">
- <?
- $issue_id = ForumIssue::getIssueIdForThread($entry['topic_id']);
- $action_menu = ActionMenu::get();
- if (!empty($entry['last_posting']['topic_id'])) {
- $action_menu->addLink(
- $controller->url_for("course/forum/index/index/{$entry['last_posting']['topic_id']}#{$entry['last_posting']['topic_id']}"),
- _('Zur letzten Antwort'),
- Icon::create('forum'),
- is_array($entry['last_posting']) ? ['class' => 'hidden-small-up'] : ['disabled' => '']
- )->condition(ForumPerm::has('edit_area', $seminar_id) && $issue_id);
- }
- $action_menu->addLink(
- URLHelper::getURL("dispatch.php/course/topics/edit/{$issue_id}"),
- _('Zum Ablaufplan'),
- Icon::create('info-circle', Icon::ROLE_STATUS_RED),
- ['title' => _('Dieser Bereich ist einem Thema zugeordnet und kann hier nicht editiert werden. Die Angaben können im Ablaufplan angepasst werden.')]
- )->condition(ForumPerm::has('edit_area', $seminar_id) && !$issue_id)
- ->addLink(
- $controller->url_for('course/forum/index', ['edit_area' => $entry['topic_id']]),
- _('Name/Beschreibung des Bereichs ändern'),
- Icon::create('edit'),
- [
- 'class' => 'edit-area',
- 'onclick' => "STUDIP.Forum.editArea('{$entry['topic_id']}');return false;",
- ]
- )->condition(ForumPerm::has('remove_area', $seminar_id))
- ->addLink(
- $controller->url_for("course/forum/index/delete_entry/{$entry['topic_id']}"),
- _('Bereich mitsamt allen Einträgen löschen!'),
- Icon::create('trash'),
- [
- 'class' => 'delete-area',
- 'onclick' => "STUDIP.Forum.deleteArea(this, '{$entry['topic_id']}'); return false;",
- ]
- ) ?>
- <?= $action_menu ?>
- </td>
-
-</tr>
diff --git a/app/views/course/forum/configs/edit.php b/app/views/course/forum/configs/edit.php
new file mode 100644
index 0000000..59d7b1d
--- /dev/null
+++ b/app/views/course/forum/configs/edit.php
@@ -0,0 +1,52 @@
+<?php
+/**
+ * @var Course_Forum_ConfigsController $controller
+ * @var CourseConfig $config
+ */
+?>
+<form class="default" method="post" action="<?= $controller->url_for('course/forum/configs/save') ?>">
+ <?= CSRFProtection::tokenTag() ?>
+
+ <label>
+ <?= _('Wer darf das Forum moderieren?') ?>
+ <select name="forum_moderation_permission">
+ <option
+ value="all"
+ <?php if ($config->FORUM_MODERATION_PERMISSION === 'all') echo 'selected'; ?>
+ >
+ <?= _('Alle Teilnehmenden der Veranstaltung') ?>
+ </option>
+
+ <option
+ value="tutor"
+ <?php if ($config->FORUM_MODERATION_PERMISSION === 'tutor') echo 'selected'; ?>
+ >
+ <?= _('Tutor/-innen und Lehrende') ?>
+ </option>
+
+ <option
+ value="dozent"
+ <?php if ($config->FORUM_MODERATION_PERMISSION === 'dozent') echo 'selected'; ?>
+ >
+ <?= _('Nur Lehrende') ?>
+ </option>
+ </select>
+ </label>
+
+ <label>
+ <input
+ type="checkbox"
+ aria-label="<?= _('Kategorien ausblenden') ?>"
+ name="forum_hide_categories_navigation"
+ <?= $config->FORUM_HIDE_CATEGORIES_NAVIGATION ? 'checked' : '' ?>
+ value="1"
+ />
+ <span>
+ <?= _('Kategorien ausblenden') ?>
+ </span>
+ </label>
+
+ <div data-dialog-button>
+ <?= \Studip\Button::create(_('Übernehmen')) ?>
+ </div>
+</form>
diff --git a/app/views/course/forum/discussion_types/index.php b/app/views/course/forum/discussion_types/index.php
new file mode 100644
index 0000000..8500d90
--- /dev/null
+++ b/app/views/course/forum/discussion_types/index.php
@@ -0,0 +1,80 @@
+<?php
+/**
+ * @var Course_Forum_DiscussionTypesController $controller
+ * @var ForumDiscussionType[] $discussion_types
+ */
+
+use Forum\ForumDiscussionType;
+
+?>
+
+<div class="forum">
+ <table class="default sortable-table">
+ <caption>
+ <?= _('Diskussionstyps') ?>
+ <span class="actions">
+ <a href="<?= $controller->url_for('course/forum/discussion_types/edit') ?>" data-dialog="width=700">
+ <?= Icon::create('add', 'clickable', ['title' => _('Neue Diskussionstyp anlegen')]) ?>
+ </a>
+ </span>
+ </caption>
+
+ <colgroup>
+ <col style="width: 10%">
+ <col>
+ <col style="width: 24px">
+ </colgroup>
+
+ <thead>
+ <tr>
+ <th><?= _('Icon') ?></th>
+ <th data-sort="text"><?= _('Name') ?></th>
+ <th class="actions"><?= _('Aktionen') ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($discussion_types as $type) : ?>
+ <tr>
+ <td>
+ <?php if ($type->icon) : ?>
+ <?= Icon::create($type->icon, ['title' => htmlReady($type->icon)])->asImg(24) ?>
+ <?php endif; ?>
+ </td>
+ <td>
+ <a href="<?= $controller->url_for('course/forum/discussion_types/show/'.$type->type_id) ?>">
+ <?= htmlReady($type->name) ?>
+ </a>
+ </td>
+
+ <td class="actions">
+ <?= ActionMenu::get()
+ ->addLink(
+ $controller->url_for('course/forum/discussion_types/edit', $type),
+ _('Bearbeiten'),
+ Icon::create('edit', 'clickable', ['title' => _('Diskussionstyp bearbeiten')]),
+ ['data-dialog' => 'width=700']
+ )
+ ->addLink(
+ $controller->url_for('course/forum/discussion_types/delete', $type),
+ _('Löschen'),
+ Icon::create('trash', 'clickable',['title' => _('Diskussionstyp löschen')]),
+ ['data-confirm' => sprintf(
+ _('Wollen Sie "%s" löschen?'),
+ $type->name
+ )]
+ );
+ ?>
+ </td>
+ </tr>
+ <?php endforeach ?>
+
+ <?php if (count($discussion_types) === 0) : ?>
+ <tr>
+ <td colspan="3" class="text-center">
+ <?= _('Es sind noch keine Diskussionstypen vorhanden.') ?>
+ </td>
+ </tr>
+ <?php endif ?>
+ </tbody>
+ </table>
+</div>
diff --git a/app/views/course/forum/index/_abo_link.php b/app/views/course/forum/index/_abo_link.php
deleted file mode 100644
index ccd689a..0000000
--- a/app/views/course/forum/index/_abo_link.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<? $js = "STUDIP.Forum.loadAction('#abolink', '"
- . (ForumAbo::has($constraint['topic_id']) ? 'remove_' : '')
- . 'abo/'. $constraint['topic_id'] ."'); return false;";
-
- $url = $controller->url_for('course/forum/index/'
- . (ForumAbo::has($constraint['topic_id']) ? 'remove_' : '')
- . 'abo/'. $constraint['topic_id']);
-?>
-
-<? $text = $constraint['area'] ? _('Diesen Bereich abonnieren') : _('Dieses Thema abonnieren') ?>
-<? if ($constraint['depth'] == 0) :
- $text = _('Komplettes Forum abonnieren');
-endif ?>
-
-<? if (!ForumAbo::has($constraint['topic_id'])) : ?>
- <?= Studip\LinkButton::create($text, $url, [
- 'title' => _('Wenn sie diesen Bereich abonnieren, erhalten Sie eine '
- . 'Stud.IP-interne Nachricht sobald in diesem Bereich '
- . 'ein neuer Beitrag erstellt wurde.'),
- 'onClick' => $js]) ?>
-<? else : ?>
- <?= Studip\LinkButton::create(_('Nicht mehr abonnieren'), $url, ['onClick' => $js]) ?>
-<? endif; ?>
diff --git a/app/views/course/forum/index/_areas.php b/app/views/course/forum/index/_areas.php
deleted file mode 100644
index 4f4fa0f..0000000
--- a/app/views/course/forum/index/_areas.php
+++ /dev/null
@@ -1,103 +0,0 @@
-<? if (empty($list)) return; ?>
-<div id="sortable_areas">
-<? foreach ($list as $category_id => $entries) : ?>
-<a name="cat_<?= $category_id ?>"></a>
-<table class="default forum <?= ForumPerm::has('sort_category', $seminar_id) ? 'movable' : '' ?>" data-category-id="<?= $category_id ?>">
- <caption class="handle">
- <? if (ForumPerm::has('sort_category', $seminar_id)) : ?>
- <?= Icon::create('arr_2down', Icon::ROLE_SORT)->asImg() ?>
- <?= Icon::create('arr_2up', Icon::ROLE_SORT)->asImg() ?>
- <? endif ?>
-
- <? if (ForumPerm::has('edit_category', $seminar_id) || ForumPerm::has('remove_category', $seminar_id)) : ?>
- <span class="actions" id="tutorCategoryIcons">
- <? if ($category_id == $seminar_id) : ?>
- <?= tooltipIcon(_('Diese vordefinierte Kategorie kann nicht bearbeitet oder gelöscht werden.'
- . ' Für Autor/innen taucht sie allerdings nur auf, wenn sie Bereiche enthält.')) ?>
- <? else : ?>
- <? if (ForumPerm::has('edit_category', $seminar_id)) : ?>
- <a href="<?= $controller->link_for('course/forum/index/?edit_category=' . $category_id) ?>"
- onClick="javascript:STUDIP.Forum.editCategoryName('<?= $category_id ?>'); return false;">
- <?= Icon::create('edit', Icon::ROLE_CLICKABLE, ['title' => 'Name der Kategorie ändern'])->asImg() ?>
- </a>
- <? endif ?>
-
- <? if(ForumPerm::has('remove_category', $seminar_id)) : ?>
- <a href="<?= $controller->link_for('course/forum/index/remove_category/' . $category_id) ?>"
- onClick="STUDIP.Forum.deleteCategory('<?= $category_id ?>'); return false;">
- <?= Icon::create('trash', Icon::ROLE_CLICKABLE, ['title' => 'Kategorie entfernen'])->asImg() ?>
- </a>
- <? endif ?>
- <? endif ?>
- </span>
- <? endif ?>
-
- <span id="tutorCategory" class="category_name">
- <? if (Request::get('edit_category') == $category_id) : ?>
- <?= $this->render_partial('course/forum/area/_edit_category_form', compact('category_id', 'categories')) ?>
- <? else : ?>
- <?= htmlReady($categories[$category_id]) ?>
- <? endif ?>
- </span>
- </caption>
-
- <colgroup>
- <col style="width: 30px">
- <col>
- <col>
- <col class="hidden-tiny-down">
- <col style="width: 20px">
- </colgroup>
-
- <thead>
- <tr>
- <th></th>
- <th> <?= _('Name des Bereichs') ?></th>
- <th data-type="answers"><?= _("Beiträge") ?></th>
- <th data-type="last_posting" class="hidden-tiny-down">
- <?= _("letzte Antwort") ?>
- </th>
- <th> <?= _('Aktionen') ?> </th>
- </tr>
- </thead>
-
-
- <tbody class="sortable">
-
- <? if (!empty($entries)) foreach ($entries as $entry) : ?>
- <?= $this->render_partial('course/forum/area/add', compact('entry')) ?>
- <? endforeach; ?>
-
- <? if ($category_id && ForumPerm::has('add_area', $seminar_id) && Request::get('add_area') == $category_id) : ?>
- <?= $this->render_partial('course/forum/area/_add_area_form') ?>
- <? endif ?>
-
- <? if (!$entries): ?>
- <!-- this row allows dropping on otherwise empty categories -->
- <tr class="sort-disabled">
- <td class="areaborder" style="height: 5px; padding: 0px; margin: 0px" colspan="5"> </td>
- </tr>
- <? endif; ?>
- </tbody>
-
- <tfoot>
- <? if ($category_id && ForumPerm::has('add_area', $seminar_id)) : ?>
- <? if (Request::get('add_area') != $category_id) : ?>
- <tr class="add_area">
- <td colspan="5" onClick="STUDIP.Forum.addArea('<?= $category_id ?>'); return false;" class="add_area">
- <a href="<?= $controller->link_for('course/forum/index/index/?add_area=' . $category_id)?>#cat_<?= $category_id ?>" title="<?= _('Neuen Bereich zu dieser Kategorie hinzufügen.') ?>">
- <span><?= _('Bereich hinzufügen') ?></span>
- <?= Icon::create('add')->asImg(["id" => 'tutorAddArea']) ?>
- </a>
- </td>
- </tr>
- <? endif ?>
- <? endif ?>
-
- <!-- bottom border -->
- </tfoot>
-</table>
-<? endforeach ?>
-</div>
-
-<?= $this->render_partial('course/forum/area/_js_templates') ?>
diff --git a/app/views/course/forum/index/_breadcrumb.php b/app/views/course/forum/index/_breadcrumb.php
deleted file mode 100644
index 1627c9f..0000000
--- a/app/views/course/forum/index/_breadcrumb.php
+++ /dev/null
@@ -1,16 +0,0 @@
-<? if ($section == 'index' || !$section) : ?>
-<div id="tutorBreadcrumb">
- <? $path = ForumEntry::getPathToPosting($topic_id) ?>
- <a href="<?= $controller->link_for('course/forum/index') ?>" title="<?= _('Übersicht') ?>">
- <?= Icon::create('forum') ?>
- </a>
-
- <? foreach ($path as $path_part) : ?>
- <? if (sizeof($path) > 1) :?>/<? endif ?>
- <a href="<?= $controller->link_for('course/forum/index/index/' . $path_part['id']) ?>">
- <?= htmlReady(ForumEntry::killFormat($path_part['name'])) ?>
- </a>
- <? $first = false ?>
- <? endforeach ?>
-</div>
-<? endif ?>
diff --git a/app/views/course/forum/index/_favorite.php b/app/views/course/forum/index/_favorite.php
deleted file mode 100644
index 7325d83..0000000
--- a/app/views/course/forum/index/_favorite.php
+++ /dev/null
@@ -1,12 +0,0 @@
-<? if (!ForumPerm::has('fav_entry', $seminar_id)) return; ?>
-
-<!-- set/unset favorite -->
-<? if (!$favorite) : ?>
- <a href="<?= $controller->link_for('course/forum/index/set_favorite/'. $topic_id) ?>" onClick="STUDIP.Forum.setFavorite('<?= $topic_id ?>');return false;">
- <?= Icon::create('staple')->asImg(['title' => _('Beitrag merken')]) ?>
- </a>
-<? else : ?>
- <a href="<?= $controller->link_for('course/forum/index/unset_favorite/'. $topic_id) ?>" onClick="STUDIP.Forum.unsetFavorite('<?= $topic_id ?>');return false;">
- <?= Icon::create('staple', Icon::ROLE_ATTENTION)->asImg(['title' => _('Beitrag nicht mehr merken')]) ?>
- </a>
-<? endif ?>
diff --git a/app/views/course/forum/index/_js_templates.php b/app/views/course/forum/index/_js_templates.php
deleted file mode 100644
index 3ee18ff..0000000
--- a/app/views/course/forum/index/_js_templates.php
+++ /dev/null
@@ -1,16 +0,0 @@
-<script type="text/html" class="confirm_dialog">
- <form action="<%- confirm %>" method="POST">
- <?= CSRFProtection::tokenTag()?>
- <div class="modaloverlay">
- <div class="messagebox">
- <div class="content">
- <%- question %>
- </div>
- <div class="buttons">
- <button class="accept button"><?= _('Ja') ?></button>
- <?= Studip\LinkButton::createCancel(_('Nein'), 'javascript:STUDIP.Forum.closeDialog()') ?>
- </div>
- </div>
- </div>
- </form>
-</script>
diff --git a/app/views/course/forum/index/_last_post.php b/app/views/course/forum/index/_last_post.php
deleted file mode 100644
index b5d854a..0000000
--- a/app/views/course/forum/index/_last_post.php
+++ /dev/null
@@ -1,21 +0,0 @@
-<? if (!empty($entry['last_posting']) && is_array($entry['last_posting'])) : ?>
- <?= _('von') ?>
- <? if (!empty($entry['last_posting']['anonymous'])): ?>
- <?= _('Anonym') ?>
- <? endif; ?>
- <? if (empty($entry['last_posting']['anonymous']) || $entry['last_posting']['user_id'] == $GLOBALS['user']->id || $GLOBALS['perm']->have_perm('root')): ?>
- <a href="<?= URLHelper::getLink('dispatch.php/profile', ['username' => $entry['last_posting']['username'] ?? '']) ?>">
- <?= htmlReady(($temp_user = User::find($entry['last_posting']['user_id'])) ? $temp_user->getFullName() : $entry['last_posting']['user_fullname'] ?? '') ?>
- </a>
- <? endif; ?>
- <br>
- <?= _('am') ?>
- <?= strftime($time_format_string_short, (int) $entry['last_posting']['date']) ?>
- <a href="<?= $controller->link_for("course/forum/index/index/{$entry['last_posting']['topic_id']}#{$entry['last_posting']['topic_id']}") ?>">
- <?= Icon::create('link-intern')->asImg([
- 'title' => _('Direkt zum Beitrag...'),
- ]) ?>
- </a>
-<? else: ?>
-<?= _('keine Antworten') ?>
-<? endif; ?>
diff --git a/app/views/course/forum/index/_like.php b/app/views/course/forum/index/_like.php
deleted file mode 100644
index da1c511..0000000
--- a/app/views/course/forum/index/_like.php
+++ /dev/null
@@ -1,48 +0,0 @@
-<?
-if (!ForumPerm::has('like_entry', $seminar_id)) return;
-
-$likes = ForumLike::getLikes($topic_id);
-shuffle($likes);
-?>
-
-<!-- the likes for this post -->
-<? if (!empty($likes)) : ?>
- <? // set the current user to the front
- $text = '';
- if (array_search($GLOBALS['user']->id, $likes) !== false) {
- if (sizeof($likes) > 1) {
- $text = '<span class="tooltip">' . sprintf(_('Dir und %s weiteren gefällt das.'), (sizeof($likes) - 1));
- $text .= '<span class="tooltip-content">';
- foreach ($likes as $user_id) {
- if ($user_id != $GLOBALS['user']->id) {
- $text .= htmlReady(get_fullname($user_id)) .'<br>';
- }
- }
- $text .= '</span></span>';
- } else {
- $text = _('Dir gefällt das.');
- }
- } else {
- $text = '<span class="tooltip">' . sprintf(_('%s gefällt das.'), sizeof($likes));
- $text .= '<span class="tooltip-content">';
- foreach ($likes as $user_id) {
- $text .= htmlReady(get_fullname($user_id)) .'<br>';
- }
- $text .= '</span></span>';
- }
-
- $text .= ' <br>';
- echo $text;
-endif ?>
-
-<!-- like/dislike links -->
-<?php $has_liked = in_array($GLOBALS['user']->id, $likes); ?>
-<button class="as-link"
- onclick="$.post('<?= $controller->action_link($has_liked ? 'dislike' : 'like', $topic_id) ?>').done(response => $('#like_<?= htmlReady($topic_id) ?>').html(response));return false;"
->
-<? if ($has_liked) : ?>
- <?= _('Gefällt mir nicht mehr!'); ?>
-<? else: ?>
- <?= _('Gefällt mir!'); ?>
-<? endif; ?>
-</button>
diff --git a/app/views/course/forum/index/_new_category.php b/app/views/course/forum/index/_new_category.php
deleted file mode 100644
index 7048a6c..0000000
--- a/app/views/course/forum/index/_new_category.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<? if (ForumPerm::has('add_category', $seminar_id)) : ?>
-<a name="create"></a>
-<form action="<?= $controller->link_for('course/forum/index/add_category') ?>" method="post" id="tutorAddCategory" class="default">
- <?= CSRFProtection::tokenTag() ?>
- <fieldset>
- <legend><?= _('Neue Kategorie erstellen') ?></legend>
-
- <label>
- <?= _('Name der Kategorie') ?>
- <input type="text" size="50" placeholder="<?= _('Titel für neue Kategorie') ?>" name="category" required>
- </label>
- </fieldset>
-
- <footer>
- <?= Studip\Button::create(_('Kategorie erstellen')) ?>
- </footer>
-</form>
-<br>
-<? endif ?>
diff --git a/app/views/course/forum/index/_new_entry.php b/app/views/course/forum/index/_new_entry.php
deleted file mode 100644
index 54a920b..0000000
--- a/app/views/course/forum/index/_new_entry.php
+++ /dev/null
@@ -1,62 +0,0 @@
-<?/* $this->flash['new_entry_title'] */ ?>
-<script type="text/html" class="new_entry_box">
- <div class="forum_new_entry" data-id="<%- topic_id %>">
- <a name="create"></a>
- <form action="<?= $controller->link_for('course/forum/index/add_entry') ?>" method="post" id="forum_new_entry" onSubmit="$(window).off('beforeunload')" class="default">
- <fieldset>
- <legend>
- <? if (!empty($constraint['depth']) && ($constraint['depth'] == 1)) : ?>
- <?= _('Neues Thema erstellen') ?>
- <? else : ?>
- <?= _('Antworten') ?>
- <? endif ?>
- </legend>
-
- <? if (!empty($constraint['depth']) && ($constraint['depth'] == 1)) : ?>
- <? if ($GLOBALS['user']->id == 'nobody') : ?>
- <label>
- <?= _('Ihr Name') ?>
- <input class="size-l" type="text" name="author" style="width: 99%"
- placeholder="<?= _('Ihr Name') ?>" required tabindex="1"><br>
- </label>
- <? endif ?>
-
- <label>
- <?= _('Titel') ?>
- <input class="size-l" type="text" name="name" style="width: 99%" value=""
- <?= !empty($constraint['depth']) && ($constraint['depth'] == 1) ? 'required' : '' ?> placeholder="<?= _('Titel') ?>" tabindex="2">
- </label>
- <? elseif ($GLOBALS['user']->id == 'nobody') : ?>
- <label>
- <?= _('Ihr Name') ?>
- <input type="text" name="author" style="width: 99%" placeholder="<?= _('Ihr Name') ?>" required tabindex="1"><br>
- </label>
- <? endif; ?>
-
- <label>
- <textarea class="wysiwyg size-l" data-editor="extraPlugins=StudipBlockQuote" data-textarea="new_entry" name="content" required tabindex="3"
- placeholder="<?= _('Schreiben Sie hier Ihren Beitrag.') ?>"></textarea>
- </label>
-
- <? if (Config::get()->FORUM_ANONYMOUS_POSTINGS): ?>
- <label>
- <input type="checkbox" name="anonymous" value="1">
- <?= _('Anonym') ?>
- </label>
- <? endif; ?>
- </fieldset>
-
- <footer>
- <?= Studip\Button::createAccept(_('Beitrag erstellen'), ['tabindex' => '3']) ?>
-
- <?= Studip\LinkButton::createCancel(_('Abbrechen'), '', [
- 'onClick' => "return STUDIP.Forum.cancelNewEntry();",
- 'tabindex' => '4']) ?>
- </footer>
-
- <input type="hidden" name="parent" value="<?= $topic_id ?>">
- <input type="text" name="nixda" style="display: none;">
- <?= CSRFProtection::tokenTag() ?>
- </form>
- </div>
-</script>
diff --git a/app/views/course/forum/index/_post.php b/app/views/course/forum/index/_post.php
deleted file mode 100644
index 6009787..0000000
--- a/app/views/course/forum/index/_post.php
+++ /dev/null
@@ -1,270 +0,0 @@
-<? $is_new = ((isset($visitdate) && $post['mkdate'] >= $visitdate) || !(isset($visitdate))) ?>
-<? if (empty($constraint)) $constraint = ForumEntry::getConstraints (ForumEntry::getParentTopicId($post['topic_id'])) ?>
-
-<? $can_edit_closed = !ForumEntry::isClosed($constraint['topic_id'])
- || (ForumEntry::isClosed($constraint['topic_id']) && ForumPerm::has('edit_closed', $constraint['seminar_id'])) ?>
-
-<? $perms = [
- 'edit' => ForumPerm::hasEditPerms($post['topic_id']),
- 'edit_closed' => ForumPerm::has('edit_closed', $constraint['seminar_id']),
- 'remove_entry' => ForumPerm::has('remove_entry', $constraint['seminar_id']),
-] ?>
-
-<!-- Anker, um zu diesem Posting springen zu können -->
-<a id="<?= $post['topic_id'] ?>"></a>
-
-<form method="post" data-topicid="<?= $post['topic_id'] ?>" action="<?= $controller->link_for('course/forum/index/update_entry/' . $post['topic_id']) ?>">
- <?= CSRFProtection::tokenTag() ?>
-
-<div class="real_posting posting<?= $highlight_topic == $post['topic_id'] ? ' highlight' : '' ?>" style="position: relative;" id="forumposting_<?= htmlReady($post['topic_id']) ?>">
- <a class="marked" href="<?= $controller->link_for('course/forum/index/unset_favorite/'. $post['topic_id']) ?>"
- onClick="STUDIP.Forum.unsetFavorite('<?= $post['topic_id'] ?>'); return false;" title="<?= _('Beitrag nicht mehr merken') ?>"
- <?= ($post['fav']) ?: 'style="display: none;"' ?> data-topic-id="<?= $post['topic_id'] ?>">
- <div></div>
- </a>
-
- <div class="postbody">
- <div class="title">
-
- <div class="small_screen" style="margin-bottom: 5px">
- <? if ($post['anonymous']): ?>
- <strong><?= _('Anonym') ?></strong>
- <?= strftime($time_format_string_short, (int)$post['mkdate']) ?>
- <? elseif (!$post['user_id']) : ?>
- <?= Avatar::getAvatar('nobody')->getImageTag(Avatar::SMALL,
- ['title' => _('Stud.IP')]) ?>
- <?= _('von Stud.IP erstellt') ?>,
- <?= strftime($time_format_string_short, (int)$post['mkdate']) ?>
- <? else : ?>
- <a href="<?= URLHelper::getLink('dispatch.php/profile', ['username' => get_username($post['user_id'])]) ?>">
- <?= Avatar::getAvatar($post['user_id'])->getImageTag(Avatar::SMALL,
- ['title' => get_username($post['user_id'])]) ?>
-
- <? if ($post['user_id'] == 'nobody' && $post['author']) : ?>
- <?= htmlReady($post['author']) ?>,
- <? else : ?>
- <?= htmlReady(get_fullname($post['user_id'])) ?>,
- <? endif ?>
- <?= strftime($time_format_string_short, (int)$post['mkdate']) ?>
- </a>
- <? endif ?>
-
- <br>
- </div>
-
- <? if ($post['depth'] < 3) : ?>
- <span data-edit-topic="<?= $post['topic_id'] ?>" <?= $edit_posting == $post['topic_id'] ? '' : 'style="display: none;"' ?>>
- <input type="text" name="name" value="<?= htmlReady($post['name_raw']) ?>" data-reset="<?= htmlReady($post['name_raw']) ?>" style="width: 100%">
- </span>
- <? else : ?>
- <? $parent_topic = ForumEntry::getConstraints(ForumEntry::getParentTopicId($post['topic_id'])) ?>
-
- <? if($constraint['closed']) : ?>
- <?= Icon::create('lock-locked', 'info', ['title' => _('Dieses Thema wurde geschlossen. Sie können daher nicht auf diesen Beitrag antworten.')])->asImg() ?>
- <? endif ?>
-
- <span data-edit-topic="<?= $post['topic_id'] ?>">
- <span name="name" value="<?= htmlReady($parent_topic['name']) ?>"></span>
- </span>
- <? endif ?>
-
- <span data-show-topic="<?= $post['topic_id'] ?>">
- <a href="<?= $controller->link_for('course/forum/index/index/' . $post['topic_id'] .'?'. http_build_query(['highlight' => $highlight]) ) ?>#<?= $post['topic_id'] ?>">
- <? if (!empty($show_full_path)) : ?>
- <?= ForumHelpers::highlight(htmlReady(implode(' >> ', ForumEntry::getFlatPathToPosting($post['topic_id']))), $highlight) ?>
- <? elseif ($post['depth'] < 3) : ?>
- <span data-topic-name="<?= $post['topic_id'] ?>">
- <? if ($edit_posting != $post['topic_id']) : ?>
- <?= ($post['name_raw'] && $post['depth'] < 3) ? ForumHelpers::highlight(htmlReady($post['name_raw']), $highlight) : ''?>
- <? endif ?>
- </span>
- <? endif ?>
- </a>
- </span>
- </div>
-
- <!-- Postinginhalt -->
- <div class="content">
- <span data-edit-topic="<?= $post['topic_id'] ?>" <?= $edit_posting == $post['topic_id'] ? '' : 'style="display: none;"' ?>>
- <textarea data-textarea="<?= $post['topic_id'] ?>" data-reset="<?= wysiwygReady($post['content_raw']) ?>" name="content" class="wysiwyg" data-editor="extraPlugins=StudipBlockQuote"><?= wysiwygReady($post['content_raw']) ?></textarea>
- </span>
-
- <span data-show-topic="<?= $post['topic_id'] ?>" data-topic-content="<?= $post['topic_id'] ?>" <?= $edit_posting != $post['topic_id'] ? '' : 'style="display: none;"' ?>>
- <?= ForumHelpers::highlight($post['content'], $highlight) ?>
- <?= OpenGraph::extract(ForumEntry::removeQuotes($post['content_raw']))->render() ?>
- </span>
- </div>
-
- <!-- Buttons for this Posting -->
- <div class="buttons">
- <div class="button-group">
-
- <span data-edit-topic="<?= $post['topic_id'] ?>" <?= ($edit_posting == $post['topic_id']) ? '' : 'style="display: none;"' ?>>
- <!-- Buttons für den Bearbeitungsmodus -->
- <?= Studip\Button::createAccept(_('Änderungen speichern'), '',
- ['onClick' => "STUDIP.Forum.saveEntry('". $post['topic_id'] ."'); return false;"]) ?>
-
- <?= Studip\LinkButton::createCancel(_('Abbrechen'), $controller->link_for('course/forum/index/index/'. $post['topic_id'] .'#'. $post['topic_id']),
- ['onClick' => "STUDIP.Forum.cancelEditEntry('". $post['topic_id'] ."'); return false;"]) ?>
- </span>
-
- <span data-show-topic="<?= $post['topic_id'] ?>" <?= $edit_posting != $post['topic_id'] ? '' : 'style="display: none;"' ?>>
- <!-- Aktions-Buttons für diesen Beitrag -->
-
-
- <? if (ForumPerm::has('add_entry', $constraint['seminar_id'])) : ?>
- <?= Studip\LinkButton::create(_('Beitrag zitieren'), $controller->url_for('course/forum/index/index/' . $post['topic_id'] .'?cite=1'), [
- 'onClick' => "javascript:STUDIP.Forum.citeEntry('". $post['topic_id'] ."'); return false;",
- 'class' => !$perms['edit_closed'] ? 'hideWhenClosed' : '',
- 'style' => !$can_edit_closed ? 'display: none' : ''
- ]) ?>
- <? endif ?>
-
- <? if ($perms['edit']) : ?>
- <?= Studip\LinkButton::create(_('Beitrag bearbeiten'), $controller->url_for('course/forum/index/index/'
- . $post['topic_id'] .'/?edit_posting=' . $post['topic_id']), [
- 'onClick' => "STUDIP.Forum.editEntry('". $post['topic_id'] ."'); return false;",
- 'class' => !$perms['edit_closed'] ? 'hideWhenClosed' : '',
- 'style' => !$can_edit_closed ? 'display: none' : ''
- ]) ?>
- <? endif ?>
-
- <span <?= (empty($perms['edit_close']) && empty($perms['remove_entry'])) ? 'class="hideWhenClosed"': '' ?>
- <?= (!$perms['edit'] && !$perms['remove_entry']) ? 'style="display: none"' : '' ?>>
- <? $confirmLink = $controller->url_for('course/forum/index/delete_entry/' . $post['topic_id']) ?>
- <? if ($constraint['depth'] == $post['depth']) : /* this is not only a posting, but a thread */ ?>
- <?= Studip\Button::create(
- _('Thema löschen'),
- 'delete_topic',
- [
- 'data-confirm' => _('Wenn Sie diesen Beitrag löschen wird ebenfalls das gesamte Thema gelöscht. Sind Sie sicher, dass Sie das tun möchten?'),
- 'formaction' => $confirmLink,
- ]
- ) ?>
- <? else : ?>
- <?= Studip\Button::create(
- _('Beitrag löschen'),
- 'delete_post',
- [
- 'data-confirm' => _('Möchten Sie diesen Beitrag wirklich löschen?'),
- 'formaction' => $confirmLink,
- ]
- ) ?>
- <? endif ?>
- </span>
-
- <? if (ForumPerm::has('forward_entry', $seminar_id)) : ?>
- <?= Studip\LinkButton::create(_('Beitrag weiterleiten'),
- "javascript:STUDIP.Forum.forwardEntry('". $post['topic_id'] ."')", ['class' => 'js']) ?>
- <? endif ?>
- </span>
- </div>
- </div>
-
- </div>
-
- <? if ($perms['edit']) : ?>
- <span data-edit-topic="<?= $post['topic_id'] ?>" <?= $edit_posting == $post['topic_id'] ? '' : 'style="display: none;"' ?>>
- <dl class="postprofile">
- <dt>
- </dt>
- </dl>
- </span>
- <? endif ?>
-
- <!-- Infobox rechts neben jedem Posting -->
- <span data-show-topic="<?= $post['topic_id'] ?>" <?= $edit_posting != $post['topic_id'] ? '' : 'style="display: none;"' ?>>
- <dl class="postprofile">
- <? if ($post['anonymous']): ?>
- <dd class="anonymous_post" data-profile="<?= $post['topic_id'] ?>"><strong><?= _('Anonym') ?></strong></dd>
- <? endif; ?>
- <? if (!$post['anonymous'] || $post['user_id'] == $GLOBALS['user']->id || $GLOBALS['perm']->have_perm('root')): ?>
- <dt>
- <? if ($post['user_id'] != 'nobody' && $post['user_id']) : ?>
- <a href="<?= URLHelper::getLink('dispatch.php/profile', ['username' => get_username($post['user_id'])]) ?>">
- <?= Avatar::getAvatar($post['user_id'])->getImageTag(Avatar::MEDIUM,
- ['title' => get_username($post['user_id'])]) ?>
- </a>
- <br>
- <? endif ?>
-
- <? if ($post['user_id'] == 'nobody') : ?>
- <?= Icon::create('community', 'info')->asImg() ?>
- <span class="username" data-profile="<?= $post['topic_id'] ?>">
- <?= htmlReady($post['author']) ?>
- </span>
- <? elseif ($post['user_id']) : ?>
-
- <!-- Online-Status -->
- <? $status = ForumHelpers::getOnlineStatus($post['user_id']) ?>
- <? if ($status === 'available') : ?>
- <?= Icon::create('community', Icon::ROLE_STATUS_GREEN, ['title' => _('Online')]) ?>
- <? elseif ($status === 'away') : ?>
- <?= Icon::create('community', Icon::ROLE_INACTIVE, ['title' => _('Abwesend')]) ?>
- <? elseif ($status === 'offline') : ?>
- <?= Icon::create('community', Icon::ROLE_INFO, ['title' => _('Offline')]) ?>
- <? endif ?>
-
- <a href="<?= URLHelper::getLink('dispatch.php/profile', ['username' => get_username($post['user_id'])])?>">
- <span class="username" data-profile="<?= $post['topic_id'] ?>">
- <?= htmlReady(get_fullname($post['user_id'])) ?>
- </span>
- </a>
- <? endif ?>
- </dt>
-
- <dd>
- <?= ForumHelpers::translate_perm($GLOBALS['perm']->get_studip_perm($constraint['seminar_id'], $post['user_id']))?>
- </dd>
- <? if ($post['user_id']) : ?>
- <dd>
- Beiträge:
- <?= ForumEntry::countUserEntries($post['user_id']) ?><br>
- <?= _('Erhaltene "Gefällt mir!":') ?>
- <?= ForumLike::receivedForUser($post['user_id']) ?>
- </dd>
- <? endif ?>
- <? endif; ?>
- <dd>
- <? if (!$post['user_id']) : ?>
- <?= _('von Stud.IP erstellt') ?><br>
- <? endif ?>
- </dd>
-
- <dd class="posting_icons">
- <!-- Favorit -->
- <span id="favorite_<?= $post['topic_id'] ?>">
- <?= $this->render_partial('course/forum/index/_favorite', ['topic_id' => $post['topic_id'], 'favorite' => $post['fav']]) ?>
- </span>
-
- <!-- Permalink -->
- <a href="<?= $controller->link_for('course/forum/index/index/' . $post['topic_id'] .'#'. $post['topic_id']) ?>">
- <?= Icon::create('group', 'clickable', ['title' => _('Link zu diesem Beitrag')])->asImg() ?>
- </a>
- <br>
-
- <!-- Like -->
- <span class="likes" id="like_<?= $post['topic_id'] ?>">
- <?= $this->render_partial('course/forum/index/_like', ['topic_id' => $post['topic_id']]) ?>
- </span>
- </dd>
-
- <? foreach (PluginEngine::sendMessage('PostingApplet', 'getHTML', $post['name_raw'], $post['content_raw'],
- $controller->link_for('course/forum/index/index/' . $post['topic_id'] .'#'. $post['topic_id']),
- $post['user_id']) as $applet_data) : ?>
- <dd>
- <?= $applet_data ?>
- </dd>
- <? endforeach ?>
- </dl>
-
- <? if ($is_new): ?>
- <span class="new_posting">
- <?= Icon::create('forum', 'attention', ['title' => _("Dieser Beitrag ist seit Ihrem letzten Besuch hinzugekommen.")])->asImg() ?>
- </span>
- <? endif ?>
- </span>
-
- <div class="clear"></div>
-</div>
-</form>
diff --git a/app/views/course/forum/index/_postings.php b/app/views/course/forum/index/_postings.php
deleted file mode 100644
index 1888ae0..0000000
--- a/app/views/course/forum/index/_postings.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<div style="clear: both">
-
-<?
-$posting_num = 1;
-if (!$section) $section = 'index';
-
-foreach ($postings as $post) :
- // show the line only once and do not show it before the first posting of a thread
- echo $this->render_partial('course/forum/index/_post', compact('post', 'visitdate', 'section'));
-
- $posting_num++;
-endforeach
-?>
-
-</div>
diff --git a/app/views/course/forum/index/_threads.php b/app/views/course/forum/index/_threads.php
deleted file mode 100644
index 6c2fc4a..0000000
--- a/app/views/course/forum/index/_threads.php
+++ /dev/null
@@ -1,197 +0,0 @@
-<br>
-<? if (trim($constraint['content'])) : ?>
- <div class="posting">
- <div class="postbody">
- <div class="content"><?= formatReady(ForumEntry::killEdit($constraint['content'])) ?></div>
- </div>
- </div>
-<? endif ?>
-
-<form action="#" method="post">
- <?= CSRFProtection::tokenTag() ?>
- <? if (!empty($list)) foreach ($list as $category_id => $entries) : ?>
- <table class="default forum" data-category-id="<?= $category_id ?>">
- <colgroup>
- <col style="width: 30px">
- <col>
- <col>
- <col class="hidden-tiny-down">
- <col style="width: 24px">
- </colgroup>
-
- <thead>
- <tr>
- <th colspan="2"><?= _('Thema') ?></th>
- <th data-type="answers"><?= _("Beiträge") ?></th>
- <th data-type="last_posting" class="hidden-tiny-down">
- <?= _('letzte Antwort') ?>
- </th>
- <th></th>
- </tr>
- </thead>
-
- <tbody>
-
- <? if (!empty($entries)) foreach ($entries as $entry) :
- $jump_to_topic_id = ($entry['last_unread'] ?: $entry['topic_id']); ?>
-
- <tr data-area-id="<?= $entry['topic_id'] ?>">
-
- <td class="icon">
- <a href="<?= $controller->link_for("course/forum/index/index/{$jump_to_topic_id}#{$jump_to_topic_id}") ?>">
- <? if ($entry['chdate'] >= $visitdate && $entry['user_id'] != $GLOBALS['user']->id): ?>
- <?= Icon::create('forum', Icon::ROLE_ATTENTION)->asImg([
- 'title' => _('Dieser Eintrag ist neu!'),
- ]) ?>
- <? else : ?>
- <? $num_postings = ForumVisit::getCount($entry['topic_id'], $visitdate) ?>
- <?= Icon::create('forum', $num_postings > 0 ? Icon::ROLE_ATTENTION : Icon::ROLE_INFO)->asImg([
- 'title' => ForumHelpers::getVisitText($num_postings, $entry['topic_id']),
- ]) ?>
- <? endif ?>
-
- <br>
-
- <?= Icon::create('lock-locked', Icon::ROLE_INFO)->asImg([
- 'title' => _('Dieses Thema ist geschlossen, es können keine neuen Beiträge erstellt werden.'),
- 'id' => "img-locked-{$entry['topic_id']}",
- 'style' => $entry['closed'] ? '' : 'display: none',
- ]) ?>
-
- <?= Icon::create('staple', Icon::ROLE_INFO)->asImg([
- 'title' => _('Dieses Thema wurde hervorgehoben.'),
- 'id' => "img-sticky-{$entry['topic_id']}",
- 'style' => $entry['sticky'] ? '' : 'display: none',
- ]) ?>
- </a>
- </td>
-
- <td class="areaentry">
- <div style="position: relative;">
- <a href="<?= $controller->link_for('course/forum/index/index/' . $entry['topic_id'] . '#' . $entry['topic_id']) ?>">
- <span class="areaname"><?= htmlReady($entry['name_raw'] ?: _('Ohne Titel')) ?></span>
- </a>
-
- <?= _("von") ?>
- <? if ($entry['anonymous']): ?>
- <?= _('Anonym') ?>
- <? endif; ?>
- <? if (!$entry['anonymous'] || $entry['user_id'] == $GLOBALS['user']->id || $GLOBALS['perm']->have_perm('root')): ?>
- <a href="<?= URLHelper::getLink('dispatch.php/profile', ['username' => get_username($entry['user_id'])]) ?>">
- <?= htmlReady(($temp_user = User::find($entry['user_id'])) ? $temp_user->getFullName() : $entry['author']) ?>
- </a>
- <? endif; ?>
- <?= _("am") ?> <?= strftime($time_format_string_short, (int)$entry['mkdate']) ?>
- <br>
-
- <?= htmlReady($entry['content_short']) ?>
- </div>
- </td>
-
- <td class="postings">
- <?= number_format($entry['num_postings'], 0, ',', '.') ?>
- </td>
-
- <td class="answer hidden-tiny-down">
- <?= $this->render_partial('course/forum/index/_last_post.php', compact('entry')) ?>
- </td>
-
- <td class="actions">
- <?= ActionMenu::get()
- ->condition(isset($entry['last_posting']))
- ->addLink(
- isset($entry['last_posting']['topic_id']) ? $controller->url_for("course/forum/index/index/{$entry['last_posting']['topic_id']}#{$entry['last_posting']['topic_id']}") : '#no_posting',
- _('Zur letzten Antwort'),
- Icon::create('forum'),
- ['class' => 'hidden-small-up']
- )
- // Make thread sticky/unsticky
- ->conditionAll(ForumPerm::has('make_sticky', $seminar_id) && $constraint['depth'] >= 1)
- ->condition(!$entry['sticky'])
- ->addLink(
- $controller->url_for('course/forum/index/make_sticky', $entry['topic_id'], $constraint['topic_id'], 0),
- _('Thema hervorheben'),
- Icon::create('staple'),
- ['id' => "stickyButton-{$entry['topic_id']}"]
- )
- ->condition($entry['sticky'])
- ->addLink(
- $controller->url_for('course/forum/index/make_unsticky', $entry['topic_id'], $constraint['topic_id'], 0),
- _('Hervorhebung aufheben'),
- Icon::create('staple'),
- ['id' => "stickyButton-{$entry['topic_id']}"]
- )
- ->conditionAll(null)
- // Move thread
- ->condition(ForumPerm::has('move_thread', $seminar_id))
- ->addLink(
- "javascript:STUDIP.Forum.moveThreadDialog('{$entry['topic_id']}');",
- _('Dieses Thema verschieben'),
- Icon::create('folder-full'),
- ['class' => 'js']
- )
- // Open/close thread
- ->conditionAll(ForumPerm::has('close_thread', $seminar_id) && $constraint['depth'] >= 1)
- ->condition(!$entry['closed'])
- ->addLink(
- $controller->url_for('course/forum/index/close_thread', $entry['topic_id'], $constraint['topic_id'], ForumHelpers::getPage()),
- _('Thema schließen'),
- Icon::create('lock-locked'),
- [
- 'id' => "closeButton-{$entry['topic_id']}",
- 'onclick' => "STUDIP.Forum.closeThreadFromOverview('{$entry['topic_id']}', '{$constraint['topic_id']}', " . ForumHelpers::getPage() . "); return false;",
- ]
- )
- ->condition($entry['closed'])
- ->addLink(
- $controller->url_for('course/forum/index/open_thread', $entry['topic_id'], $constraint['topic_id'], ForumHelpers::getPage()),
- _('Thema öffnen'),
- Icon::create('lock-unlocked'),
- [
- 'id' => "closeButton-{$entry['topic_id']}",
- 'onclick' => "STUDIP.Forum.openThreadFromOverview('{$entry['topic_id']}', '{$constraint['topic_id']}', " . ForumHelpers::getPage() . "); return false;",
- ]
- )
- ->conditionAll(null)
- // Delete thread
- ->condition(ForumPerm::has('remove_entry', $seminar_id))
- ->addButton(
- 'delete',
- _('Dieses Thema löschen'),
- Icon::create('trash'),
- [
- 'formaction' => $controller->url_for("course/forum/index/delete_entry/{$entry['topic_id']}"),
- 'data-confirm' => sprintf(
- _('Sind sie sicher dass Sie den Eintrag %s löschen möchten?'),
- kill_format($entry['name'])
- )
- ]
- )
- ?>
-
- <? if (ForumPerm::has('move_thread', $seminar_id)) : ?>
- <div id="dialog_<?= $entry['topic_id'] ?>" style="display: none"
- title="<?= _('Bereich, in den dieser Thread verschoben werden soll:') ?>">
- <? $path = ForumEntry::getPathToPosting($entry['topic_id']);
- $paths = array_slice($path, sizeof($path) - 2, 1);
- $parent = array_pop($paths); ?>
-
- <? foreach ($areas['list'] as $area_id => $area): ?>
- <? if ($area_id != $parent['id']) : ?>
- <div style="font-size: 16px; margin-bottom: 5px;">
- <a href="<?= $controller->link_for('course/forum/index/move_thread/' . $entry['topic_id'] . '/' . $area_id) ?>">
- <?= Icon::create('arr_2right', Icon::ROLE_SORT) ?>
- <?= htmlReady($area['name_raw']) ?>
- </a>
- </div>
- <? endif ?>
- <? endforeach ?>
- </div>
- <? endif ?>
- </td>
- </tr>
- <? endforeach ?>
- </tbody>
- </table>
- <? endforeach ?>
-</form>
diff --git a/app/views/course/forum/index/index.php b/app/views/course/forum/index/index.php
deleted file mode 100644
index 1839783..0000000
--- a/app/views/course/forum/index/index.php
+++ /dev/null
@@ -1,264 +0,0 @@
-<script>
- // for some reason jQuery(document).ready(...) is not always working...
- jQuery(function () {
- STUDIP.Forum.seminar_id = '<?= $seminar_id ?>';
- STUDIP.Forum.init();
- });
-</script>
-
-<?= $this->render_partial('course/forum/index/_js_templates') ?>
-
-<!-- set a CSS "namespace" for Forum -->
-<div id="forum">
-<?php
-
-$sidebar = Sidebar::get();
-
-if (ForumPerm::has('search', $seminar_id)) {
- $search = new SearchWidget($controller->url_for('course/forum/index/search?backend=search'));
- $search->setId('tutorSearchInfobox');
- $search->addNeedle(_('Beiträge durchsuchen'), 'searchfor', true);
- $search->addFilter(_('Titel'), 'search_title');
- $search->addFilter(_('Inhalt'), 'search_content');
- $search->addFilter(_('Autor/-in'), 'search_author');
- $sidebar->addWidget($search);
-}
-
-$actions = new ActionsWidget();
-
-if ($section == 'index') {
- if (ForumPerm::has('abo', $seminar_id)) {
- if (ForumAbo::has($constraint['topic_id'])) :
- $abo_text = _('Nicht mehr abonnieren');
- $abo_url = $controller->url_for('course/forum/index/remove_abo/' . $constraint['topic_id']);
- else :
- switch ($constraint['depth']) {
- case '0': $abo_text = _('Komplettes Forum abonnieren');break;
- case '1': $abo_text = _('Diesen Bereich abonnieren');break;
- default: $abo_text = _('Dieses Thema abonnieren');break;
- }
-
- $abo_url = $controller->url_for('course/forum/index/abo/' . $constraint['topic_id']);
- endif;
-
- $actions->addLink($abo_text, $abo_url, Icon::create('link-intern'));
- }
-
- if (ForumPerm::has('close_thread', $seminar_id) && $constraint['depth'] > 1) {
- if ($constraint['closed'] == 0) {
- $close_url = $controller->url_for('course/forum/index/close_thread/'
- . $constraint['topic_id'] .'/'. $constraint['topic_id'] .'/'. ForumHelpers::getPage());
- $close = new LinkElement(
- _('Thema schließen'),
- $close_url,
- Icon::create('lock-locked'),
- [
- 'onclick' => 'STUDIP.Forum.closeThreadFromThread(\'' . $constraint['topic_id'] . '\', '
- . ForumHelpers::getPage() . '); return false;',
- 'class' => "closeButtons"
- ]
- );
- $actions->addElement($close, 'closethread');
- } else {
- $open_url = $controller->url_for('course/forum/index/open_thread/'
- . $constraint['topic_id'] .'/'. $constraint['topic_id'] .'/'. ForumHelpers::getPage());
- $open = new LinkElement(
- _('Thema öffnen'),
- $open_url,
- Icon::create('lock-unlocked'),
- [
- 'onclick' => 'STUDIP.Forum.openThreadFromThread(\'' . $constraint['topic_id'] . '\', '
- . ForumHelpers::getPage() . '); return false;',
- 'class' => "closeButtons"
- ]
- );
- $actions->addElement($open, 'closethread');
- }
- }
-
- if (ForumPerm::has('make_sticky', $seminar_id) && $constraint['depth'] > 1) {
- if ($constraint['sticky'] == 0) {
- $emphasize_url = $controller->url_for('course/forum/index/make_sticky/'
- . $constraint['topic_id'] .'/'. $constraint['topic_id'] .'/'. ForumHelpers::getPage());
- $emphasize = new LinkElement(
- _('Thema hervorheben'),
- $emphasize_url,
- Icon::create('staple'),
- [
- 'onclick' => 'STUDIP.Forum.makeThreadStickyFromThread(\'' . $constraint['topic_id'] . '\', '
- . ForumHelpers::getPage() . '); return false;',
- 'id' => "stickyButton"
- ]
- );
- $actions->addElement($emphasize, 'emphasize');
- } else {
- $unemphasize_url = $controller->url_for('course/forum/index/make_unsticky/'
- . $constraint['topic_id'] .'/'. $constraint['topic_id'] .'/'. ForumHelpers::getPage());
- $emphasize = new LinkElement(
- _('Hervorhebung aufheben'),
- $unemphasize_url,
- Icon::create('staple'),
- [
- 'onclick' => 'STUDIP.Forum.makeThreadUnstickyFromThread(\'' . $constraint['topic_id'] . '\', '
- . ForumHelpers::getPage() . '); return false;',
- 'id' => "stickyButton"
- ]
- );
- $actions->addElement($emphasize, 'emphasize');
- }
- }
-
- if ($constraint['depth'] == 0 && ForumPerm::has('add_category', $seminar_id)) {
- $actions->addLink(_('Neue Kategorie erstellen'), "#create", Icon::create('link-intern'));
- }
-}
-
-$sidebar->addWidget($actions);
-
-if ($section === 'index' && ForumPerm::has('pdfexport', $seminar_id)) {
- $export = new ExportWidget();
- $export->addLink(_('Beiträge als PDF exportieren'),
- $controller->url_for('course/forum/index/pdfexport/' . $constraint['topic_id']),
- Icon::create('file-pdf'));
- $sidebar->addWidget($export);
-}
-
-$pagechooser = null;
-?>
-
-<!-- Breadcrumb navigation -->
-<?= $this->render_partial('course/forum/index/_breadcrumb') ?>
-
-<!-- Seitenwähler (bei Bedarf) am oberen Rand anzeigen -->
-<? if (!empty($number_of_entries) && $number_of_entries > ForumEntry::POSTINGS_PER_PAGE) : ?>
-<div data-type="page_chooser" id="page-chooser">
- <? if (!isset($constraint) || $constraint['depth'] > 0) : ?>
- <?= $pagechooser = $GLOBALS['template_factory']->render('shared/pagechooser', [
- 'page' => ForumHelpers::getPage(),
- 'num_postings' => $number_of_entries,
- 'perPage' => ForumEntry::POSTINGS_PER_PAGE,
- 'pagelink' => str_replace('%%s', '%s', str_replace('%', '%%', $controller->url_for('course/forum/index/goto_page/'. $topic_id .'/'. $section
- .'/%s/?searchfor=' . ($searchfor ?? '') . (!empty($options) ? '&'. http_build_query($options) : '' ))))
- ]); ?>
- <? endif ?>
- <?= $link ?? '' ?>
-</div>
-<? endif ?>
-
-<!-- Message area -->
-<div id="message_area" style="clear: both">
- <?= $this->render_partial('course/forum/messages') ?>
-</div>
-
-<? if (!empty($no_entries)) : ?>
- <?= MessageBox::info(_('In dieser Ansicht befinden sich zur Zeit keine Beiträge.')) ?>
-<? endif ?>
-
-<!-- Bereiche / Themen darstellen -->
-<? if (empty($constraint['depth'])) : ?>
- <?= $this->render_partial('course/forum/index/_areas') ?>
-<? elseif ($constraint['depth'] == 1) : ?>
- <?= $this->render_partial('course/forum/index/_threads') ?>
-<? endif ?>
-
-<? if (!empty($postings)) : ?>
- <!-- Beiträge für das ausgewählte Thema darstellen -->
- <?= $this->render_partial('course/forum/index/_postings') ?>
-<? endif ?>
-
-<!-- Seitenwähler (bei Bedarf) am unteren Rand anzeigen -->
-<? if ($pagechooser) : ?>
-<div style="float: right; padding-right: 10px;" data-type="page_chooser">
- <?= $pagechooser ?>
-</div>
-<? endif ?>
-
-<!-- Erstellen eines neuen Elements (Kategorie, Thema, Beitrag) -->
-<? if (empty($constraint['depth'])) : ?>
- <div style="clear: right; text-align: center">
- <div class="button-group">
- <? if (ForumPerm::has('abo', $seminar_id) && $section == 'index') : ?>
- <span id="abolink">
- <?= $this->render_partial('course/forum/index/_abo_link', compact('constraint')) ?>
- </span>
- <? endif ?>
-
- <? if (ForumPerm::has('pdfexport', $seminar_id) && $section == 'index') : ?>
- <?= Studip\LinkButton::create(_('Beiträge als PDF exportieren'), $controller->url_for('course/forum/index/pdfexport'), ['target' => '_blank']) ?>
- <? endif ?>
- </div>
- </div>
-
- <? if ($section == 'index' && ForumPerm::has('add_category', $seminar_id)) : ?>
- <?= $this->render_partial('course/forum/index/_new_category') ?>
- <? endif ?>
-<? else : ?>
- <? if (!$flash['edit_entry'] && ForumPerm::has('add_entry', $seminar_id)) : ?>
- <? $constraint['depth'] == 1 ? $button_face = _('Neues Thema erstellen') : $button_face = _('Antworten') ?>
- <div id="new_entry_button">
- <div style="clear: right; text-align: center">
- <div class="button-group">
- <? if ($constraint['depth'] <= 1 || ($constraint['closed'] == 0)) : ?>
- <?= Studip\LinkButton::create($button_face, $controller->url_for('course/forum/index/index/'. $topic_id .'?answer=1'),
- ['onClick' => 'STUDIP.Forum.answerEntry(); return false;',
- 'class' => 'hideWhenClosed',]) ?>
- <? endif ?>
- <? if ($constraint['depth'] > 1 && ($constraint['closed'] == 1)) : ?>
- <?= Studip\LinkButton::create($button_face, $controller->url_for('course/forum/index/index/' . $topic_id. '?answer=1'),
- ['onClick' => 'STUDIP.Forum.answerEntry(); return false;',
- 'class' => 'hideWhenClosed',
- 'style' => 'display:none;'
- ]) ?>
- <? endif ?>
-
- <? if (ForumPerm::has('close_thread', $seminar_id) && $constraint['depth'] > 1) : ?>
- <? if ($constraint['closed'] == 0): ?>
- <?= Studip\LinkButton::create(_('Thema schließen'),
- $controller->url_for('course/forum/index/close_thread/' . $topic_id .'/'. $topic_id .'/'. ForumHelpers::getPage()), [
- 'onClick' => 'STUDIP.Forum.closeThreadFromThread("'. $topic_id .'"); return false;',
- 'class' => 'closeButtons']
- ) ?>
- <? else: ?>
- <?= Studip\LinkButton::create(_('Thema öffnen'),
- $controller->url_for('course/forum/index/open_thread/' . $topic_id .'/'. $topic_id .'/'. ForumHelpers::getPage()), [
- 'onClick' => 'STUDIP.Forum.openThreadFromThread("'. $topic_id .'"); return false;',
- 'class' => 'closeButtons']
- ) ?>
- <? endif ?>
- <? endif ?>
-
- <? if ($constraint['depth'] > 0 && ForumPerm::has('abo', $seminar_id)) : ?>
- <span id="abolink">
- <?= $this->render_partial('course/forum/index/_abo_link', compact('constraint')) ?>
- </span>
- <? endif ?>
-
- <? if (ForumPerm::has('pdfexport', $seminar_id)) : ?>
- <?= Studip\LinkButton::create(_('Beiträge als PDF exportieren'), $controller->url_for('course/forum/index/pdfexport/' . $topic_id), ['target' => '_blank']) ?>
- <? endif ?>
- </div>
- </div>
- </div>
- <? endif ?>
-<? endif ?>
-
-<? if ( (ForumPerm::has('add_area', $this->seminar_id))
- || (isset($constraint['depth']) && $constraint['depth'] >= 1 && ForumPerm::has('add_entry', $seminar_id)) ): ?>
- <?= $this->render_partial('course/forum/index/_new_entry') ?>
- <? endif ?>
-</div>
-
-<!-- Mail-Notifikationen verschicken (soweit am Ende der Seite wie möglich!) -->
-<? if ($flash['notify']) :
- ForumAbo::notify($flash['notify']);
-endif ?>
-
-<? if ($js == 'answer') : ?>
-<script>jQuery(function() {
- STUDIP.Forum.answerEntry();
-});</script>
-<? elseif ($js == 'cite') : ?>
-<script>jQuery(function() {
- STUDIP.Forum.citeEntry('<?= $cite_id ?>');
-});</script>
-<? endif ?>
diff --git a/app/views/course/forum/messages.php b/app/views/course/forum/messages.php
deleted file mode 100644
index 246a85d..0000000
--- a/app/views/course/forum/messages.php
+++ /dev/null
@@ -1,7 +0,0 @@
-<? if (!empty($flash['messages'])) foreach ($flash['messages'] as $type => $message): ?>
- <? if ($type == 'info_html') : ?>
- <?= MessageBox::info($message) ?>
- <? else : ?>
- <?= MessageBox::$type(htmlReady($message)) ?>
- <? endif ?>
-<? endforeach ?>
diff --git a/cli/Commands/Make/Plugin.php b/cli/Commands/Make/Plugin.php
index 1405c6b..ce9af97 100644
--- a/cli/Commands/Make/Plugin.php
+++ b/cli/Commands/Make/Plugin.php
@@ -26,7 +26,6 @@ final class Plugin extends Command
\ExternPagePlugin::class,
\FilesystemPlugin::class,
\FileUploadHook::class,
- \ForumModule::class,
\HomepagePlugin::class,
\LibraryPlugin::class,
\MetricsPlugin::class,
diff --git a/db/migrations/6.1.6_forum3.php b/db/migrations/6.1.6_forum3.php
new file mode 100644
index 0000000..914fae2
--- /dev/null
+++ b/db/migrations/6.1.6_forum3.php
@@ -0,0 +1,400 @@
+<?php
+class Forum3 extends Migration
+{
+ public function description()
+ {
+ return "A new version of the forum for Stud.IP.";
+ }
+
+ public function up()
+ {
+ \DBManager::get()->exec("
+ ALTER TABLE `forum_categories`
+ CHANGE `seminar_id` `range_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ CHANGE `pos` `position` int(11) NOT NULL DEFAULT 0,
+ CHANGE `entry_name` `name` varchar(255) NOT NULL,
+ ADD COLUMN `description` text DEFAULT NULL AFTER `name`,
+ ADD COLUMN `color` VARCHAR(7) AFTER `description`,
+ ADD COLUMN `chdate` INT(11) DEFAULT NULL AFTER `position`,
+ ADD COLUMN `mkdate` INT(11) DEFAULT NULL AFTER `chdate`
+ ");
+
+ \DBManager::get()->exec("
+ ALTER TABLE `forum_entries`
+ ADD KEY `lft` (`lft`),
+ ADD KEY `rgt` (`rgt`)
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_topics` (
+ `topic_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `category_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin DEFAULT NULL,
+ `range_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `name` text NOT NULL,
+ `description` text DEFAULT NULL,
+ `position` INT(11) NOT NULL DEFAULT 0,
+ `chdate` INT(11) NOT NULL,
+ `mkdate` INT(11) NOT NULL,
+ PRIMARY KEY (`topic_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ INSERT INTO `forum_topics` (`topic_id`, `category_id`, `range_id`, `name`, `description`, `position`, `chdate`, `mkdate`)
+ SELECT `forum_entries`.`topic_id`,
+ `forum_categories_entries`.`category_id`,
+ `forum_entries`.`seminar_id`,
+ `forum_entries`.`name`,
+ `forum_entries`.`content`,
+ IFNULL(`forum_categories_entries`.`pos`, 0),
+ `forum_entries`.`latest_chdate`,
+ `forum_entries`.`mkdate`
+ FROM `forum_entries`
+ LEFT JOIN `forum_categories_entries` ON (`forum_categories_entries`.`topic_id` = `forum_entries`.`topic_id`)
+ WHERE `forum_entries`.`depth` = 1
+ GROUP BY `forum_entries`.`topic_id`
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_discussions` (
+ `discussion_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `topic_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `type_id` INT(11) DEFAULT NULL,
+ `title` text NOT NULL,
+ `sticky` tinyINT(1) NOT NULL DEFAULT 0,
+ `closed_at` INT(11),
+ `view_count` INT NOT NULL DEFAULT 0,
+ `chdate` INT(11) NOT NULL,
+ `mkdate` INT(11) NOT NULL,
+ PRIMARY KEY (`discussion_id`),
+ KEY `topic_id` (`topic_id`)
+ )
+ ");
+ \DBManager::get()->exec("
+ INSERT INTO `forum_discussions` (`discussion_id`, `topic_id`, `sticky`, `title`, `chdate`, `mkdate`)
+ SELECT `forum_entries`.`topic_id`,
+ `parent_fe`.`topic_id`,
+ `forum_entries`.`sticky`,
+ `forum_entries`.`name`,
+ `forum_entries`.`latest_chdate`,
+ `forum_entries`.`mkdate`
+ FROM `forum_entries`
+ LEFT JOIN `forum_entries` AS `parent_fe` ON (`parent_fe`.depth = 1 AND `parent_fe`.`lft` < `forum_entries`.`lft` AND `parent_fe`.`rgt` > `forum_entries`.`rgt` AND `parent_fe`.`seminar_id` = `forum_entries`.`seminar_id`)
+ WHERE `forum_entries`.`depth` = 2
+ GROUP BY `forum_entries`.`topic_id`
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE `forum_postings` (
+ `posting_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `discussion_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `range_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `parent_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin,
+ `content` TEXT NOT NULL,
+ `quote` TEXT,
+ `user_id` CHAR(32) NOT NULL,
+ `anonymous` tinyINT(1) NOT NULL DEFAULT 0,
+ `chdate` INT(11) NOT NULL,
+ `mkdate` INT(11) NOT NULL,
+ PRIMARY KEY (`posting_id`),
+ KEY `discussion_id` (`discussion_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ INSERT INTO `forum_postings` (`posting_id`, `discussion_id`, `range_id`, `content`, `user_id`, `anonymous`, `chdate`, `mkdate`)
+ SELECT `forum_entries`.`topic_id`,
+ `forum_entries`.`topic_id`,
+ `forum_entries`.`seminar_id`,
+ `forum_entries`.`content`,
+ `forum_entries`.`user_id`,
+ `forum_entries`.`anonymous`,
+ `forum_entries`.`latest_chdate`,
+ `forum_entries`.`mkdate`
+ FROM `forum_entries`
+ LEFT JOIN `forum_entries` AS `parent_fe` ON (`parent_fe`.depth = 1 AND `parent_fe`.`lft` < `forum_entries`.`lft` AND `parent_fe`.`rgt` > `forum_entries`.`rgt` AND `parent_fe`.`seminar_id` = `forum_entries`.`seminar_id`)
+ WHERE `forum_entries`.`depth` = 2
+ GROUP BY `forum_entries`.`topic_id`
+ ");
+
+ \DBManager::get()->exec("
+ INSERT INTO `forum_postings` (`posting_id`, `discussion_id`, `range_id`, `content`, `user_id`, `anonymous`, `chdate`, `mkdate`)
+ SELECT `forum_entries`.`topic_id`,
+ `parent_fe`.`topic_id`,
+ `forum_entries`.`seminar_id`,
+ `forum_entries`.`content`,
+ `forum_entries`.`user_id`,
+ `forum_entries`.`anonymous`,
+ `forum_entries`.`latest_chdate`,
+ `forum_entries`.`mkdate`
+ FROM `forum_entries`
+ LEFT JOIN `forum_entries` AS `parent_fe` ON (`parent_fe`.depth = 2 AND `parent_fe`.`lft` < `forum_entries`.`lft` AND `parent_fe`.`rgt` > `forum_entries`.`rgt` AND `parent_fe`.`seminar_id` = `forum_entries`.`seminar_id`)
+ WHERE `forum_entries`.`depth` = 3
+ GROUP BY `forum_entries`.`topic_id`
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE `forum_discussion_types` (
+ `type_id` INT(11) unsigned NOT NULL AUTO_INCREMENT,
+ `name` varCHAR(255) NOT NULL,
+ `icon` varCHAR(50) DEFAULT NULL,
+ `chdate` INT(11) NOT NULL,
+ `mkdate` INT(11) NOT NULL,
+ PRIMARY KEY (`type_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE `forum_posting_reactions` (
+ `id` INT(11) unsigned NOT NULL AUTO_INCREMENT,
+ `posting_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `user_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `emoji` varCHAR(50) DEFAULT NULL,
+ `chdate` INT(11) NOT NULL,
+ `mkdate` INT(11) NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `posting_id` (`posting_id`),
+ KEY `user_id` (`user_id`),
+ KEY `emoji` (`emoji`)
+ )
+ ");
+ \DBManager::get()->exec("
+ INSERT INTO `forum_posting_reactions` (`posting_id`, `user_id`, `emoji`, `chdate`, `mkdate`)
+ SELECT `topic_id`, `user_id`, 'THUMBS UP SIGN', UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
+ FROM `forum_likes`
+ "); // THUMBS UP SIGN, THUMBS DOWN SIGN, ROCKET, GRINNING FACE, SMILING FACE WITH SUNGLASSES, CONFUSED FACE, BLACK HEART SUIT, PARTY POPPER
+
+ \DBManager::get()->exec("
+ CREATE TABLE `forum_posting_reads` (
+ `discussion_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `user_id` CHAR(32) NOT NULL,
+ `read_index` INT(11) NOT NULL DEFAULT 0,
+ `chdate` INT(11) NOT NULL,
+ PRIMARY KEY (`discussion_id`, `user_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE `forum_subscriptions` (
+ `id` INT(11) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` CHAR(32) NOT NULL,
+ `range_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `subject_id` CHAR(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `subject` ENUM('discussion', 'topic') NOT NULL DEFAULT 'discussion',
+ `notification_type` ENUM('all', 'replies_only', 'none') NOT NULL DEFAULT 'all',
+ `chdate` INT(11) NOT NULL,
+ `mkdate` INT(11) NOT NULL,
+ PRIMARY KEY (`id`)
+ )
+ ");
+
+ $insertConfigSql = "INSERT IGNORE INTO `config` VALUES (:field, :value, :type, :range, :section, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description)";
+
+ \DBManager::get()->execute(
+ $insertConfigSql,
+ [
+ 'field' => 'FORUM_MODERATION_PERMISSION',
+ 'value' => 'dozent',
+ 'type' => 'string',
+ 'range' => 'course',
+ 'section' => 'Forum',
+ 'description' => 'Status, den es braucht, um das Forum zu moderieren.'
+ ]
+ );
+
+ \DBManager::get()->execute(
+ $insertConfigSql,
+ [
+ 'field' => 'FORUM_HIDE_CATEGORIES_NAVIGATION',
+ 'value' => 0,
+ 'type' => 'boolean',
+ 'range' => 'course',
+ 'section' => 'Forum',
+ 'description' => 'Bestimmt, ob die Kategorien-Navigation im Forum ausgeblendet wird.'
+ ]
+ );
+
+ \DBManager::get()->execute(
+ $insertConfigSql,
+ [
+ 'field' => 'FORUM_TILE_LAYOUT',
+ 'value' => 1,
+ 'type' => 'boolean',
+ 'range' => 'user',
+ 'section' => 'Forum',
+ 'description' => 'Konfiguration der Ansicht des Forum.'
+ ]
+ );
+
+ \DBManager::get()->exec("
+ RENAME TABLE `forum_entries_issues` TO `forum_topics_issues`;
+ ");
+
+ $insertDiscussionTypeSql = "INSERT IGNORE INTO `forum_discussion_types` VALUES (MD5(:name), :name, :icon, UNIX_TIMESTAMP(), UNIX_TIMESTAMP())";
+
+ $discussionTypes = [
+ [
+ 'name' => 'Fragen',
+ 'icon' => 'question'
+ ],
+ [
+ 'name' => 'Aufgaben',
+ 'icon' => 'guestbook'
+ ],
+ [
+ 'name' => 'Ideen',
+ 'icon' => 'lightbulb'
+ ],
+ [
+ 'name' => 'Regeln',
+ 'icon' => 'info-circle'
+ ],
+ [
+ 'name' => 'Vorstellung',
+ 'icon' => 'vcard'
+ ],
+ [
+ 'name' => 'Organisation',
+ 'icon' => 'network2'
+ ]
+ ];
+
+ foreach ($discussionTypes as $discussionType) {
+ \DBManager::get()->execute($insertDiscussionTypeSql, $discussionType);
+ }
+
+ \DBManager::get()->exec("
+ DROP TABLE IF EXISTS
+ `forum_likes`,
+ `forum_visits`,
+ `forum_abo_users`,
+ `forum_favorites`,
+ `forum_user_roles`,
+ `forum_categories_entries`,
+ `forum_entries`
+ ");
+ }
+
+ public function down()
+ {
+ $removeConfigSql = "DELETE `config`, `config_values` FROM `config`
+ LEFT JOIN `config_values` USING (`field`)
+ WHERE `field` = :field";
+
+ \DBManager::get()->execute(
+ $removeConfigSql,
+ ['field' => 'FORUM_TILE_LAYOUT']
+ );
+ \DBManager::get()->execute(
+ $removeConfigSql,
+ ['field' => 'FORUM_HIDE_CATEGORIES_NAVIGATION']
+ );
+ \DBManager::get()->execute(
+ $removeConfigSql,
+ ['field' => 'FORUM_MODERATION_PERMISSION']
+ );
+ \DBManager::get()->execute(
+ $removeConfigSql,
+ ['field' => 'FORUM_TILE_LAYOUT']
+ );
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_entries` (
+ `topic_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `seminar_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `area` tinyint NOT NULL DEFAULT '0',
+ `mkdate` int unsigned NOT NULL,
+ `latest_chdate` int unsigned DEFAULT NULL,
+ `chdate` int unsigned NOT NULL,
+ `author` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `author_host` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `lft` int NOT NULL,
+ `rgt` int NOT NULL,
+ `depth` int NOT NULL,
+ `anonymous` tinyint NOT NULL DEFAULT '0',
+ `closed` tinyint unsigned NOT NULL DEFAULT '0',
+ `sticky` tinyint unsigned NOT NULL DEFAULT '0',
+ PRIMARY KEY (`topic_id`),
+ KEY `seminar_id` (`seminar_id`,`lft`),
+ KEY `seminar_id_2` (`seminar_id`,`rgt`),
+ KEY `user_id` (`user_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_likes` (
+ `topic_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ PRIMARY KEY (`topic_id`,`user_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_visits` (
+ `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `seminar_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `visitdate` int unsigned NOT NULL,
+ `last_visitdate` int unsigned NOT NULL,
+ PRIMARY KEY (`user_id`,`seminar_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_abo_users` (
+ `topic_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ PRIMARY KEY (`topic_id`,`user_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_favorites` (
+ `user_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `topic_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ PRIMARY KEY (`user_id`,`topic_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ DROP TABLE IF EXISTS `forum_categories`
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_categories` (
+ `category_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `seminar_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `entry_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
+ `pos` int NOT NULL DEFAULT '0',
+ PRIMARY KEY (`category_id`),
+ KEY `seminar_id` (`seminar_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ CREATE TABLE IF NOT EXISTS `forum_categories_entries` (
+ `category_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `topic_id` char(32) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+ `pos` int NOT NULL DEFAULT '0',
+ PRIMARY KEY (`category_id`,`topic_id`)
+ )
+ ");
+
+ \DBManager::get()->exec("
+ RENAME TABLE `forum_topics_issues` TO `forum_entries_issues`;
+ ");
+
+ \DBManager::get()->exec("
+ DROP TABLE IF EXISTS
+ `forum_subscriptions`,
+ `forum_posting_reads`,
+ `forum_posting_reactions`,
+ `forum_discussion_types`,
+ `forum_postings`,
+ `forum_discussions`,
+ `forum_topics`
+ ");
+ }
+}
diff --git a/lib/activities/CourseContext.php b/lib/activities/CourseContext.php
index 1beb4d3..3419ed2 100644
--- a/lib/activities/CourseContext.php
+++ b/lib/activities/CourseContext.php
@@ -33,7 +33,6 @@ class CourseContext extends Context
$course = $this->course;
$module_provider = [
- 'CoreForum' => 'ForumProvider',
'CoreParticipants' => 'ParticipantsProvider',
'CoreDocuments' => 'DocumentsProvider',
'CoreWiki' => 'WikiProvider',
diff --git a/lib/activities/ForumProvider.php b/lib/activities/ForumProvider.php
deleted file mode 100644
index 6a958eb..0000000
--- a/lib/activities/ForumProvider.php
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-
-/**
- * @author Till Glöggler <tgloeggl@uos.de>
- * @author André Klaßen <klassen@elan-ev.de>
- * @license GPL 2 or later3
- */
-
-
-namespace Studip\Activity;
-
-class ForumProvider implements ActivityProvider
-{
- /**
- * get the details for the passed activity
- *
- * @param object $activity the activity to fill with details, passed by reference
- */
- public function getActivityDetails($activity)
- {
- $post = \ForumEntry::getEntry($activity->object_id);
-
- if (!$post) {
- return false;
- }
-
- $activity->content = formatReady($post['content']);
-
- $url = \URLHelper::getURL('dispatch.php/course/forum/index/index/' . $post['topic_id']
- .'?cid='. $post['seminar_id'] .'&highlight_topic='. $post['topic_id']
- .'#'. $post['topic_id']);
-
- $activity->object_url = [
- $url => _('Zum Forum der Veranstaltung')
- ];
-
- return true;
- }
-
- /**
- * {@inheritdoc}
- */
- public static function getLexicalField()
- {
- return _('einen Forenbeitrag');
- }
-
-}
diff --git a/lib/activities/InstituteContext.php b/lib/activities/InstituteContext.php
index 0f7689d..1f2c3de 100644
--- a/lib/activities/InstituteContext.php
+++ b/lib/activities/InstituteContext.php
@@ -32,7 +32,6 @@ class InstituteContext extends Context
$institute = $this->institute;
$module_provider = [
- 'CoreForum' => 'ForumProvider',
'CoreDocuments' => 'DocumentsProvider',
'CoreWiki' => 'WikiProvider',
];
diff --git a/lib/archiv.inc.php b/lib/archiv.inc.php
index e40685f..0e0912d 100644
--- a/lib/archiv.inc.php
+++ b/lib/archiv.inc.php
@@ -64,10 +64,8 @@ function lastActivity ($sem_id)
WHERE `range_id` = :id";
}
- foreach (PluginEngine::getPlugins(ForumModule::class) as $plugin) {
- $table = $plugin->getEntryTableInfo();
- $queries[] = 'SELECT MAX(`'. $table['chdate'] .'`) AS chdate FROM `'. $table['table'] .'` WHERE `'. $table['seminar_id'] .'` = :id';
- }
+ // Forum
+ $queries[] = 'SELECT MAX(`chdate`) AS chdate FROM `forum_postings` WHERE `range_id` = :id';
$query = "SELECT MAX(chdate) FROM (" . implode(' UNION ', $queries) . ") AS tmp";
}
diff --git a/lib/classes/Forum/DTO/ForumMember.php b/lib/classes/Forum/DTO/ForumMember.php
new file mode 100644
index 0000000..a6f4ea4
--- /dev/null
+++ b/lib/classes/Forum/DTO/ForumMember.php
@@ -0,0 +1,53 @@
+<?php
+namespace Forum\DTO;
+
+use Avatar;
+use Context;
+use User;
+
+class ForumMember
+{
+ public function __construct(
+ public string $id,
+ public string $username,
+ public string $name,
+ public string $avatar_url,
+ public string $role
+ ) {}
+
+ public static function fromArray(array $data = []): self
+ {
+ return new self(
+ $data['id'] ?? '',
+ $data['username'] ?? '',
+ $data['name'] ?? '',
+ $data['avatar_url'] ?? '',
+ $data['role'] ?? ''
+ );
+ }
+
+ public static function fromUser(User $user, $course_id = null): self
+ {
+ $course_id = $course_id ?? Context::getId();
+ $role = $GLOBALS['perm']->get_studip_perm($course_id, $user->user_id);
+
+ return self::fromArray([
+ 'id' => $user->user_id,
+ 'username' => $user->username,
+ 'name' => $user->getFullName(),
+ 'avatar_url' => Avatar::getAvatar($user->user_id)->getURL(Avatar::NORMAL),
+ 'role' => in_array($role, ['dozent', 'tutor']) ? 'moderator' : 'author'
+ ]);
+ }
+
+ public function toRawArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'username' => $this->username,
+ 'name' => $this->name,
+ 'avatar_url' => $this->avatar_url,
+ 'role' => $this->role
+ ];
+ }
+}
diff --git a/lib/classes/Forum/DTO/ForumTag.php b/lib/classes/Forum/DTO/ForumTag.php
new file mode 100644
index 0000000..ca0e045
--- /dev/null
+++ b/lib/classes/Forum/DTO/ForumTag.php
@@ -0,0 +1,45 @@
+<?php
+namespace Forum\DTO;
+
+use DBManager;
+
+class ForumTag
+{
+ public function __construct(
+ public string $id,
+ public string $name
+ ) {}
+
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ $data['id'] ?? '',
+ $data['name'] ?? ''
+ );
+ }
+
+ public function toRawArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name
+ ];
+ }
+
+ public static function getForumTags(): array
+ {
+ return DBManager::get()->fetchAll(
+ "SELECT DISTINCT `tags_relations`.`tag_id`, `tags`.`name` FROM `tags`
+ LEFT JOIN `tags_relations` ON `tags`.`id` = `tags_relations`.`tag_id`
+ WHERE `tags_relations`.`range_type` = 'forum' AND `tags`.`active` = TRUE
+ ORDER BY `tags`.`mkdate` DESC",
+ [],
+ function ($tag) {
+ return self::fromArray([
+ 'id' => $tag['tag_id'],
+ 'name' => $tag['name']
+ ]);
+ }
+ );
+ }
+}
diff --git a/lib/classes/Forum/Enum/SubscriptionNotificationType.php b/lib/classes/Forum/Enum/SubscriptionNotificationType.php
new file mode 100644
index 0000000..ee46714
--- /dev/null
+++ b/lib/classes/Forum/Enum/SubscriptionNotificationType.php
@@ -0,0 +1,25 @@
+<?php
+namespace Forum\Enum;
+
+enum SubscriptionNotificationType: string {
+ case All = 'all';
+ case RepliesOnly = 'replies_only';
+ case None = 'none';
+
+ public static function getTypes(): array {
+ return [
+ self::All->value => [
+ 'value' => self::All->value,
+ 'label' => _('Alle')
+ ],
+ self::RepliesOnly->value => [
+ 'value' => self::RepliesOnly->value,
+ 'label' => _('Nur Antworten')
+ ],
+ self::None->value => [
+ 'value' => self::None->value,
+ 'label' => _('Keine')
+ ]
+ ];
+ }
+}
diff --git a/lib/classes/Forum/Service/DiscussionNotification.php b/lib/classes/Forum/Service/DiscussionNotification.php
new file mode 100644
index 0000000..c3dcfe8
--- /dev/null
+++ b/lib/classes/Forum/Service/DiscussionNotification.php
@@ -0,0 +1,62 @@
+<?php
+namespace Forum\Service;
+
+use Forum\Enum\SubscriptionNotificationType;
+use Icon;
+use PersonalNotifications;
+use Forum\ForumDiscussion;
+use Forum\ForumSubscription;
+use Forum\ForumTopic;
+use URLHelper;
+
+class DiscussionNotification
+{
+ protected ForumTopic $topic;
+ protected ForumDiscussion $discussion;
+
+ public function __construct(ForumDiscussion $discussion)
+ {
+ $this->discussion = $discussion;
+ $this->topic = $discussion->topic;
+ }
+
+ public function notifySubscribers(): void
+ {
+ $subscribers = $this->getSubscribers();
+
+ foreach ($subscribers as $subscriber) {
+ $this->sendNotifications($subscriber);
+ }
+ }
+
+ protected function getSubscribers(): array
+ {
+ return ForumSubscription::findBySQL(
+ "subject = :subject AND subject_id = :subject_id AND notification_type = :notification_type",
+ [
+ 'subject' => 'topic',
+ 'subject_id' => $this->topic->topic_id,
+ 'notification_type' => SubscriptionNotificationType::All->value
+ ]
+ );
+ }
+
+ protected function sendNotifications(ForumSubscription $subscriber): void
+ {
+ $url = URLHelper::getURL('dispatch.php/course/forum/discussions/show/'.$this->discussion->discussion_id, ['cid' => $this->topic->range_id], true);
+
+ $message = sprintf(
+ _('Es gibt eine neue Diskussion „%1$s“ zum Thema „%2$s“.'),
+ $this->discussion->title,
+ $this->topic->name
+ );
+
+ PersonalNotifications::add(
+ $subscriber->user_id,
+ $url,
+ $message,
+ null,
+ Icon::create('forum')
+ );
+ }
+}
diff --git a/lib/classes/Forum/Service/PostingNotification.php b/lib/classes/Forum/Service/PostingNotification.php
new file mode 100644
index 0000000..5e46535
--- /dev/null
+++ b/lib/classes/Forum/Service/PostingNotification.php
@@ -0,0 +1,140 @@
+<?php
+namespace Forum\Service;
+
+use Forum\Enum\SubscriptionNotificationType;
+use Icon;
+use PersonalNotifications;
+use Forum\ForumDiscussion;
+use Forum\ForumPosting;
+use Forum\ForumSubscription;
+use Forum\ForumTopic;
+use URLHelper;
+
+class PostingNotification
+{
+ protected ForumPosting $posting;
+ protected ForumDiscussion $discussion;
+ protected ForumTopic $topic;
+
+ public function __construct(ForumPosting $posting)
+ {
+ $this->posting = $posting;
+ $this->topic = $posting->discussion->topic;
+ $this->discussion = $posting->discussion;
+ }
+
+ public function notifySubscribers(): void
+ {
+ $excludeUserId = null;
+ if ($this->posting->parent_id) {
+ $subscriber = $this->notifyParentPostAuthor();
+
+ if ($subscriber) {
+ $excludeUserId = $subscriber->user_id;
+ }
+ }
+
+ $subscribers = $this->getSubscribers($excludeUserId);
+
+ foreach ($subscribers as $subscriber) {
+ if ($subscriber->user_id === $this->posting->user_id || $subscriber->notification_type !== SubscriptionNotificationType::All->value) {
+ continue;
+ }
+
+ $this->sendNotifications($subscriber);
+ }
+ }
+
+ protected function getSubscribers($excludeUserId = null): array
+ {
+ $query = [
+ "range_id = :range_id AND subject_id IN (:subject_ids)",
+ [
+ 'range_id' => $this->posting->range_id,
+ 'subject_ids' => [$this->discussion->discussion_id, $this->topic->topic_id]
+ ]
+ ];
+
+ if ($excludeUserId) {
+ $query[0] .= " AND user_id != :user_id";
+ $query[1]['user_id'] = $excludeUserId;
+ }
+
+ $subscriptions = ForumSubscription::findBySQL(...$query);
+
+ /**
+ * Allow only one subscription per user.
+ * 'discussion' subscription has priority over 'topic' subscription
+ */
+ $filteredSubscriptions = [];
+ foreach ($subscriptions as $subscription) {
+ $userId = $subscription->user_id;
+
+ if (isset($filtered[$userId])) {
+ if ($filteredSubscriptions[$userId]->subject === 'discussion') {
+ continue;
+ }
+
+ // If current subscription is discussion, replace it with topic
+ if ($subscription->subject === 'discussion') {
+ $filteredSubscriptions[$userId] = $subscription;
+ }
+
+ continue;
+ }
+
+ $filteredSubscriptions[$userId] = $subscription;
+ }
+
+ return array_values($filteredSubscriptions);
+ }
+
+ protected function sendNotifications(ForumSubscription $subscriber): void
+ {
+ $url = URLHelper::getURL('dispatch.php/course/forum/discussions/show/'.$this->discussion->discussion_id, ['cid' => $this->topic->range_id], true)."#post_" . $this->posting->posting_id;
+
+ $message = sprintf(
+ _('Es gibt einen neuen Beitrag zur Diskussion „%s“.'),
+ $this->discussion->title
+ );
+
+ PersonalNotifications::add(
+ $subscriber->user_id,
+ $url,
+ $message,
+ "post_" . $this->posting->posting_id,
+ Icon::create('reply')
+ );
+ }
+
+ protected function notifyParentPostAuthor(): ?ForumSubscription
+ {
+ $parent = $this->posting->posting;
+
+ $subscriber = ForumSubscription::findOneBySQL(
+ "range_id = :range_id AND subject_id IN (:subject_ids) AND user_id = :user_id AND notification_type != :notification_type ORDER BY subject",
+ [
+ 'range_id' => $parent->range_id,
+ 'subject_ids' => [$this->discussion->discussion_id, $this->topic->topic_id],
+ 'user_id' => $parent->user_id,
+ 'notification_type' => SubscriptionNotificationType::None->value
+ ]
+ );
+
+ if ($subscriber && $subscriber->user_id !== $this->posting->user_id) {
+ \PersonalNotifications::add(
+ $subscriber->user_id,
+ \URLHelper::getURL('dispatch.php/course/forum/discussions/show/'.$this->posting->discussion_id, ['cid' => $this->topic->range_id], true)."#post_" . $this->posting->posting_id,
+ sprintf(
+ _('%s hat ihren Beitrag zur Diskussion „%s“ zitiert.'),
+ $this->posting->user->getFullName(),
+ $this->discussion->title
+ ),
+ "post_" . $this->posting->posting_id,
+ \Icon::create('quote')
+ );
+ }
+
+ return $subscriber;
+ }
+}
diff --git a/lib/classes/ForumAbo.php b/lib/classes/ForumAbo.php
deleted file mode 100644
index 4c4c2ff..0000000
--- a/lib/classes/ForumAbo.php
+++ /dev/null
@@ -1,187 +0,0 @@
-<?php
-
-/**
- * ForumAbo.php - Handle abonnements of areas/threads or even the whole forum
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumAbo
-{
- /**
- * add the passed user as a watcher for the passed topic (including all
- * current and future childs)
- *
- * @param string $topic_id
- * @param string $user_id
- */
- public static function add($topic_id, $user_id = null)
- {
- if (!$user_id) $user_id = $GLOBALS['user']->id;
-
- $stmt = DBManager::get()->prepare("REPLACE INTO forum_abo_users
- (topic_id, user_id) VALUEs (?, ?)");
- $stmt->execute([$topic_id, $user_id]);
- }
-
- /**
- * remove the passed user as a watcher from the passed topic (including all
- * current and future childs)
- *
- * @param string $topic_id
- * @param string $user_id
- */
- public static function delete($topic_id, $user_id = null)
- {
- if (!$user_id) $user_id = $GLOBALS['user']->id;
-
- $stmt = DBManager::get()->prepare("DELETE FROM forum_abo_users
- WHERE topic_id = ? AND user_id = ?");
- $stmt->execute([$topic_id, $user_id]);
- }
-
- /**
- * check, if the passed user watches the passed topic. If no user_id is passed,
- * the currently logged in user is used
- *
- * @param string $topic_id
- * @param string $user_id
- *
- * @return boolean returns true if user is watching, false otherwise
- */
- public static function has($topic_id, $user_id = null)
- {
- if (!$user_id) $user_id = $GLOBALS['user']->id;
-
- $stmt = DBManager::get()->prepare("SELECT COUNT(*) FROM forum_abo_users
- WHERE topic_id = ? AND user_id = ?");
- $stmt->execute([$topic_id, $user_id]);
-
- return $stmt->fetchColumn() > 0 ? true : false;
- }
-
- /**
- * send out the notification messages for the passed topic. The contents
- * and a link directly to the topic are added to the message.
- *
- * @param string $topic_id
- */
- public static function notify($topic_id)
- {
- // send message to all abo-users
- $db = DBManager::get();
- $messaging = new messaging();
-
- // get all parent topic-ids, to find out which users to notify
- $path = ForumEntry::getPathToPosting($topic_id);
-
- // fetch all users to notify, exclude current user
- $stmt = $db->prepare("SELECT DISTINCT user_id
- FROM forum_abo_users
- JOIN auth_user_md5 USING (user_id)
- WHERE topic_id IN (:topic_ids)
- AND user_id != :user_id");
- $stmt->bindValue(':topic_ids', array_keys($path), StudipPDO::PARAM_ARRAY);
- $stmt->bindValue(':user_id', $GLOBALS['user']->id);
- $stmt->execute();
-
- // get details for topic
- $topic = ForumEntry::getConstraints($topic_id);
-
- if (!$topic) {
- return;
- }
-
- while ($data = $stmt->fetch(PDO::FETCH_ASSOC)) {
- $user_id = $data['user_id'];
-
- // don't notify user if view permission is not granted
- if (!ForumPerm::has('view', $topic['seminar_id'], $user_id)) {
- continue;
- }
-
- $user = User::find($user_id);
-
- setTempLanguage($data['user_id']);
- $notification = sprintf(
- _('%s hat einen Beitrag geschrieben'),
- $topic['anonymous'] ? _('Anonym') : $topic['author']
- );
- restoreLanguage();
-
- PersonalNotifications::add(
- $user_id,
- URLHelper::getURL(
- 'dispatch.php/course/forum/index/index/' . $topic['topic_id'] . '#' . $topic['topic_id'],
- ['cid' => $topic['seminar_id']],
- true
- ),
- $notification,
- "forumposting_" . $topic['topic_id'],
- Icon::create('forum', 'clickable')
- );
-
- // check if user wants an email for all or selected messages only
- if (!$user->isBlocked() && $messaging->user_wants_email($user_id)) {
- $title = implode(' >> ', ForumEntry::getFlatPathToPosting($topic_id));
-
- $subject = _('[Forum]') . ' ' . ($title ?: _('Neuer Beitrag'));
-
- $template = $GLOBALS['template_factory']->open('mail/html');
- $htmlTemplate = $GLOBALS['template_factory']->open('mail/forum_notification');
- $content = $htmlTemplate->render(
- compact('user_id', 'topic', 'path')
- );
-
- $htmlMessage = $template->render([
- 'snd_fullname' => '',
- 'rec_username' => '',
- 'rec_fullname' => $user->getFullName(),
- 'message' => $content,
- 'attachments' => [],
- ]);
-
- $textMessage = trim(kill_format($content));
-
- $userWantsHtml = UserConfig::get($user_id)->MAIL_AS_HTML;
-
- StudipMail::sendMessage(
- $user->email,
- $subject,
- $textMessage,
- $userWantsHtml ? $htmlMessage : null
- );
- }
- }
- }
-
- /**
- * Removes all abos for a given course and user
- *
- * @param String $course_id Id of the course
- * @param String $user_id Id of the user
- * @return int number of removed abos
- */
- public static function removeForCourseAndUser($course_id, $user_id)
- {
- $query = "DELETE FROM `forum_abo_users`
- WHERE `user_id` = :user_id
- AND `topic_id` IN (
- SELECT `topic_id`
- FROM `forum_entries`
- WHERE `seminar_id` = :course_id
- )";
- $statement = DBManager::get()->prepare($query);
- $statement->bindValue(':course_id', $course_id);
- $statement->bindValue(':user_id', $user_id);
- $statement->execute();
- return $statement->rowCount();
- }
-}
diff --git a/lib/classes/ForumActivity.php b/lib/classes/ForumActivity.php
deleted file mode 100644
index 0f6cbf8..0000000
--- a/lib/classes/ForumActivity.php
+++ /dev/null
@@ -1,149 +0,0 @@
-<?php
-/**
- * File - description
- *
- * 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 Till Glöggler <tgloeggl@uos.de>
- * @license https://www.gnu.org/licenses/gpl-2.0.html GPL version 2
- */
-
-class ForumActivity
-{
- /**
- * Post activity for new forum post
- *
- * @param string $event
- * @param string $topic_id
- * @param array $post
- */
- public static function newEntry($event, $topic_id, $post)
- {
- $verb = isset($post['depth']) && $post['depth'] === 3 ? 'answered' : 'created';
-
- if ($verb === 'created') {
- if (isset($post['depth']) && (int)$post['depth'] === 1) {
- $summary = _('%s hat im Forum der Veranstaltung "%s" einen Bereich erstellt.');
- } else {
- $summary = _('%s hat im Forum der Veranstaltung "%s" ein Thema erstellt.');
- }
- } else {
- $summary = _('%s hat im Forum der Veranstaltung "%s" auf ein Thema geantwortet.');
- }
-
- self::post($post, $verb, $summary);
- }
-
- /**
- * Post activity for updating a forum post
- * @param string $event
- * @param string $topic_id
- * @param array $post
- */
- public static function updateEntry($event, $topic_id, $post)
- {
- $summary = _('%s hat im Forum der Veranstaltung "%s" einen Beitrag editiert.');
-
- if ($post['user_id'] == $GLOBALS['user']->id) {
- $content = sprintf(
- _('%s hat seinen eigenen Beitrag vom %s editiert.'),
- self::getPostUsername($post),
- date('d.m.y, H:i', $post['mkdate'])
- );
- } else {
- $content = sprintf(
- _('%s hat den Beitrag von %s vom %s editiert.'),
- get_fullname($GLOBALS['user']->id),
- self::getPostUsername($post),
- date('d.m.y, H:i', $post['mkdate'])
- );
- }
-
- self::post($post, 'edited', $summary, $content);
- }
-
- /**
- * Post activity for deleting a forum post
- * $param string $event
- * @param string $topic_id
- * @param array $post
- */
- public static function deleteEntry($event, $topic_id, $post)
- {
- // Remove all previous activities for the post
- Studip\Activity\Activity::deleteBySQL(
- "provider = ? AND object_type = 'forum' AND object_id = ?",
- [Studip\Activity\ForumProvider::class, $topic_id]
- );
-
- $summary = _('%s hat im Forum der Veranstaltung "%s" einen Beitrag gelöscht.');
-
- if ($post['user_id'] == $GLOBALS['user']->id) {
- $content = sprintf(
- _('%s hat seinen Beitrag vom %s gelöscht.'),
- self::getPostUsername($post),
- date('d.m.y, H:i', $post['mkdate'])
- );
- } else {
- $content = sprintf(
- _('%s hat den Beitrag von %s vom %s gelöscht.'),
- get_fullname($GLOBALS['user']->id),
- self::getPostUsername($post),
- date('d.m.y, H:i', $post['mkdate'])
- );
- }
-
- self::post($post, 'deleted', $summary, $content);
- }
-
- private static function post($post, $verb, $summary, $content = null)
- {
- // skip system-created entries like "Allgemeine Diskussionen"
- if (!$post['user_id']) {
- return;
- }
-
- $range_id = $post['seminar_id'];
- $type = get_object_type($range_id);
-
- $obj = get_object_name($range_id, $type);
-
- $data = [
- 'provider' => 'Studip\Activity\ForumProvider',
- 'context' => $type === 'sem' ? 'course' : 'institute',
- 'context_id' => $post['seminar_id'],
- 'content' => null,
- 'actor_type' => 'user', // who initiated the activity?
- 'actor_id' => $post['user_id'], // id of initiator
- 'verb' => $verb, // the activity type
- 'object_id' => $post['topic_id'], // the id of the referenced object
- 'object_type' => 'forum', // type of activity object
- 'mkdate' => $post['mkdate'] ?? time()
- ];
-
- if (!empty($post['anonymous'])) {
- $data['actor_type'] = 'anonymous';
- $data['actor_id'] = '';
- }
-
- Studip\Activity\Activity::create($data);
- }
-
- /**
- * Returns the poster's name for a forum post.
- *
- * @param array $post
- * @return string
- */
- private static function getPostUsername($post)
- {
- if (!empty($post['anonymous'])) {
- return _('Anonym');
- }
-
- return get_fullname($post['user_id']);
- }
-}
diff --git a/lib/classes/ForumEntry.php b/lib/classes/ForumEntry.php
deleted file mode 100644
index 4726dcb..0000000
--- a/lib/classes/ForumEntry.php
+++ /dev/null
@@ -1,1418 +0,0 @@
-<?php
-/**
- * ForumEntry.php - Allows the retrieval and handling of forum-entrys
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumEntry implements PrivacyObject
-{
- const WITH_CHILDS = true;
- const WITHOUT_CHILDS = false;
- const THREAD_PREVIEW_LENGTH = 100;
- const POSTINGS_PER_PAGE = 10;
- const FEED_POSTINGS = 100;
-
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
- * H E L P E R - F U N C T I O N S *
- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
-
- /**
- * is used for posting-preview. replaces all newlines with spaces
- *
- * @param string $text the text to work on
- * @returns string
- */
- public static function br2space($text)
- {
- return str_replace("\n", ' ', str_replace("\r", '', $text));
- }
-
- /**
- * remove the edit-html from a posting
- *
- * @param string $description the posting-content
- * @return string the content stripped by the edit-mark
- */
- public static function killEdit($description)
- {
- // wurde schon mal editiert
- if (preg_match('/^(.*)(<admin_msg.*?)$/s', $description, $match)) {
- return $match[1];
- }
- return $description;
- }
-
- /**
- * add the edit-html to a posting
- *
- * @param string $description the posting-content
- * @return string the content with the edit-mark
- */
- public static function appendEdit($description)
- {
- $edit = "<admin_msg autor=\"" . addslashes(get_fullname()) . "\" chdate=\"" . time() . "\">";
- return $description . $edit;
- }
-
- /**
- * convert the edit-html to raw text
- *
- * @param string $description the posting-content
- * @return string the content with the raw text version of the edit-mark
- */
- public static function parseEdit($description, $anonymous = false)
- {
- // TODO figure out if this function can be removed
- // has been replaced with getContentAsHTML in core code
- $content = ForumEntry::killEdit($description);
- $comment = ForumEntry::getEditComment($description, $anonymous);
- return $content . ($comment ? "\n\n%%" . $comment .'%%' : '');
- }
-
- /**
- * Get content with appended edit comment as HTML.
- *
- * @param string $description Database entry of forum entry's body.
- * @param bool $anonymous True, if only root is allowed to see
- * authors.
- * @return string Content and edit comment as HTML.
- */
- public static function getContentAsHtml($description, $anonymous = false)
- {
- $raw_content = ForumEntry::killEdit($description);
-
- $comment = ForumEntry::getEditComment($description, $anonymous);
- $content = formatReady($raw_content);
-
- if ($comment) {
- $content .= '<br><em>' . htmlReady($comment) . '</em>';
- }
-
- return $content;
- }
-
- /**
- * Get author and time of an edited forum entry as a string.
- *
- * @param string $description Database entry of forum entry's body.
- * @param bool $anonymous True, if only root is allowed to see
- * authors.
- * @return string Author and time or empty string if not edited.
- */
- public static function getEditComment($description, $anonymous = false)
- {
- $info = ForumEntry::getEditInfo($description);
- if ($info) {
- $root = $GLOBALS['perm']->have_perm('root');
- $author = ($anonymous && !$root) ? _('Anonym') : $info['author'];
- $time = date('d.m.y - H:i', $info['time']);
- return '[' . _('Zuletzt editiert von') . " $author - $time]";
- }
- return '';
- }
-
- /**
- * Get author and time of an edited forum entry.
- *
- * @param string $description Database entry of forum entry's body.
- * @return array|bool Associative array containing author and time.
- * boolean False if edit tag was not found.
- */
- public static function getEditInfo($description) {
- if (preg_match('/<admin_msg autor="([^"]*)" chdate="([^"]*)">\s*$/i', $description, $matches)) {
- // wurde schon mal editiert
- return ['author' => $matches[1], 'time' => $matches[2]];
- }
- return false;
- }
-
- /**
- * Remove all quote blocks AND the quoted text from a forum post.
- *
- * @param String $description The string to remove the quote blocks from
- * @return String the posting without the [quote]-blocks (not just tags!)
- */
- public static function removeQuotes($description)
- {
- if (Studip\Markup::isHtml($description)) {
- // remove all blockquote tags
- $dom = new DOMDocument();
- $old_libxml_error = libxml_use_internal_errors(true);
- $dom->loadHtml($description, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
- libxml_use_internal_errors($old_libxml_error);
- $nodes = iterator_to_array($dom->getElementsByTagName('blockquote'));
-
- foreach ($nodes as $node) {
- $node->parentNode->removeChild($node);
- }
-
- return $dom->saveHTML();
- } else {
- $description = preg_replace('/\[quote(=.*)\].*\[\/quote\]/isU', '', $description);
- $description = str_replace('[/quote]', '', $description);
- }
- return $description;
- }
-
-
- /**
- * calls Stud.IP's kill_format
- *
- * @param string $text the text to parse
- * @return string the text without format-tags
- */
- public static function killFormat($text)
- {
- return kill_format($text);
- }
-
- /**
- * returns the entry for the passed topic_id
- *
- * @param string $topic_id
- * @return array | bool array('lft' => ..., 'rgt' => ..., seminar_id => ...)
- *
- * @throws Exception
- */
- public static function getConstraints($topic_id)
- {
- //very bad performance if topic_id is 0 or false
- if (!$topic_id) {
- return false;
- }
-
- // look up the range of postings
- $range_stmt = DBManager::get()->prepare("SELECT *
- FROM forum_entries WHERE topic_id = ?");
- $range_stmt->execute([$topic_id]);
- if (!$data = $range_stmt->fetch(PDO::FETCH_ASSOC)) {
- return false;
- }
-
- if ($data['depth'] == 1) {
- $data['area'] = 1;
- }
-
- return $data;
- }
-
- /**
- * return the topic_id of the parent element, false if there is none (ie the
- * passed topic_id is already the upper-most node in the tree)
- *
- * @param string $topic_id the topic_id for which the parent shall be found
- *
- * @return string the topic_id of the parent element or false
- */
- public static function getParentTopicId($topic_id)
- {
- $path = ForumEntry::getPathToPosting($topic_id);
- array_pop($path);
- $data = array_pop($path);
-
- return $data['id'] ?? false;
- }
-
-
- /**
- * get the topic_ids of all childs of the passed topic including itself
- *
- * @param string $topic_id the topic_id to find the childs for
- * @return array a list if topic_ids
- */
- public static function getChildTopicIds($topic_id)
- {
- $constraints = ForumEntry::getConstraints($topic_id);
- if (!$constraints) {
- return [];
- }
-
- $stmt = DBManager::get()->prepare("SELECT topic_id
- FROM forum_entries WHERE lft >= ? AND rgt <= ?
- AND seminar_id = ?");
- $stmt->execute([$constraints['lft'], $constraints['rgt'], $constraints['seminar_id']]);
-
- return $stmt->fetchAll(PDO::FETCH_COLUMN);
- }
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * * * * *
- * D A T A - R E T R I E V A L *
- * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
-
- /**
- * get the page the passed posting is on
- *
- * @param string $topic_id
- * @return int
- */
- public static function getPostingPage($topic_id, $constraint = null)
- {
- if (!$constraint) {
- $constraint = ForumEntry::getConstraints($topic_id);
- }
-
- if (!$constraint) {
- return 0;
- }
- // this calculation only works for postings
- if ($constraint['depth'] <= 2) {
- return ForumHelpers::getPage();
- }
-
- if ($parent_id = ForumEntry::getParentTopicId($topic_id)) {
- $parent_constraint = ForumEntry::getConstraints($parent_id);
-
- if ($parent_constraint) {
- return ceil((($constraint['lft'] - $parent_constraint['lft'] + 3) / 2) / ForumEntry::POSTINGS_PER_PAGE);
- }
- }
-
- return 0;
- }
-
- /**
- * return the id for the oldest unread child-posting for the passed topic.
- *
- * @param string $parent_id
- * @return string id of oldest unread posting
- */
- public static function getLastUnread($parent_id)
- {
- $constraint = ForumEntry::getConstraints($parent_id);
-
- if (!$constraint) {
- return null;
- }
-
- // take users visitdate into account
- $visitdate = ForumVisit::getLastVisit($constraint['seminar_id']);
-
- // get the first unread entry
- $stmt = DBManager::get()->prepare("SELECT * FROM forum_entries
- WHERE lft > ? AND rgt < ? AND seminar_id = ?
- AND mkdate >= ?
- ORDER BY mkdate ASC LIMIT 1");
- $stmt->execute([$constraint['lft'], $constraint['rgt'], $constraint['seminar_id'], $visitdate]);
- $last_unread = $stmt->fetch(PDO::FETCH_ASSOC);
-
- return $last_unread ? $last_unread['topic_id'] : null;
- }
-
- /**
- * retrieve the the latest posting under $parent_id
- * or false if the postings itself is the latest
- *
- * @param string $parent_id the node to lookup the childs in
- * @return array | bool the data for the latest postings or false
- */
- public static function getLatestPosting($parent_id)
- {
- $constraint = ForumEntry::getConstraints($parent_id);
- if (!$constraint) {
- return false;
- }
-
- $stmt = DBManager::get()->prepare("SELECT * FROM forum_entries
- WHERE lft > ? AND rgt < ? AND seminar_id = ?
- ORDER BY mkdate DESC LIMIT 1");
- $stmt->execute([$constraint['lft'], $constraint['rgt'], $constraint['seminar_id']]);
-
- return $stmt->fetch(PDO::FETCH_ASSOC) ?: false;
- }
-
- /**
- * returns a hashmap with arrays containing id and name with the entries
- * which lead to the passed topic
- *
- * @param string $topic_id the topic to get the path for
- *
- * @return array
- */
- public static function getPathToPosting($topic_id)
- {
- $data = ForumEntry::getConstraints($topic_id);
- if (!$data) {
- return [];
- }
-
- $ret = [];
-
- $stmt = DBManager::get()->prepare("SELECT * FROM forum_entries
- WHERE lft <= ? AND rgt >= ? AND seminar_id = ? ORDER BY lft ASC");
- $stmt->execute([$data['lft'], $data['rgt'], $data['seminar_id']]);
-
- while ($data = $stmt->fetch(PDO::FETCH_ASSOC)) {
- $ret[$data['topic_id']] = $data;
- $ret[$data['topic_id']]['id'] = $data['topic_id'];
- }
- // set the name of the first entry to the name of the category the entry is in
- if (count($ret) > 1) {
- $tmp = array_slice($ret, 1, 1);
- $area = array_pop($tmp);
- $top = current($ret);
- $ret[$top['id']]['name'] = ForumCat::getCategoryNameForArea($area['id']) ?: _('Allgemein');
- }
-
- return $ret;
- }
-
- /**
- * returns a hashmap where key is topic_id and value a posting-title from the
- * entries which lead to the passed topic.
- *
- * WARNING: This function ommits postings with an empty title. For a full
- * list please use ForumEntry::getPathToPosting()!
- *
- * @param string $topic_id the topic to get the path for
- *
- * @return array
- */
- public static function getFlatPathToPosting($topic_id)
- {
- // use only the part of the path until the thread, no posting title
- $postings = array_slice(self::getPathToPosting($topic_id), 0, 3);
- $ret = [];
- foreach ($postings as $post) {
- if ($post['name']) {
- $ret[$post['id']] = $post['name'];
- }
- }
-
- return $ret;
- }
-
- /**
- * fill the passed postings with additional data
- *
- * @param array $postings
- * @return array
- */
- public static function parseEntries($postings)
- {
- $posting_list = [];
-
- // retrieve the postings
- foreach ($postings as $data) {
- // we throw away all formatting stuff, tags, etc, leaving the important bit of information
- $desc_short = ForumEntry::br2space(ForumEntry::killFormat($data['content']));
- if (mb_strlen($desc_short) > (ForumEntry::THREAD_PREVIEW_LENGTH + 2)) {
- $desc_short = mb_substr($desc_short, 0, ForumEntry::THREAD_PREVIEW_LENGTH) . '...';
- }
-
- $posting_list[$data['topic_id']] = [
- 'author' => $data['author'],
- 'topic_id' => $data['topic_id'],
- 'name' => formatReady($data['name']),
- 'name_raw' => $data['name'],
- 'content' => ForumEntry::getContentAsHtml($data['content'], $data['anonymous']),
- 'content_raw' => ForumEntry::killEdit($data['content']),
- 'content_short' => $desc_short,
- 'chdate' => $data['chdate'],
- 'mkdate' => $data['mkdate'],
- 'user_id' => $data['user_id'],
- 'raw_title' => $data['name'],
- 'raw_description' => ForumEntry::killEdit($data['content']),
- 'fav' => (!empty($data['fav']) && ($data['fav'] == 'fav')),
- 'depth' => $data['depth'],
- 'anonymous' => $data['anonymous'],
- 'closed' => $data['closed'],
- 'sticky' => $data['sticky'],
- 'seminar_id' => $data['seminar_id']
- ];
- } // retrieve the postings
-
- return $posting_list;
- }
-
- /**
- * Get all entries for the passed parent_id.
- * Returns an array of the following structure:
- * Array (
- * 'list' => Array (
- * 'author' =>
- * 'topic_id' =>
- * 'name' => formatReady()
- * 'name_raw' =>
- * 'content' => formatReady()
- * 'content_raw' =>
- * 'content_short' =>
- * 'chdate' =>
- * 'mkdate' =>
- * 'user_id' =>
- * 'raw_title' =>
- * 'raw_description' =>
- * 'fav' =>
- * 'depth' =>
- * 'sticky' =>
- * 'closed' =>
- * 'seminar_id' =>
- * )
- * 'count' =>
- * )
- *
- * @param string $parent_id id of parent-element to get entries for.
- * @param boolean $with_childs if true, the whole subtree is fetched
- * @param string $add for additional constraints in the WHERE-part of the query
- * @param string $sort_order can be ASC or DESC
- * @param int $start can be used for pagination, is used for the LIMIT-part of the query
- * @param int $limit number of entries to fetch, defaults to ForumEntry::POSTINGS_PER_PAGE
- *
- * @return array
- *
- * @throws Exception if the retrieval failed, an Exception is thrown
- */
- public static function getEntries($parent_id, $with_childs = false, $add = '',
- $sort_order = 'DESC', $start = 0, $limit = ForumEntry::POSTINGS_PER_PAGE)
- {
- $constraint = ForumEntry::getConstraints($parent_id);
- if (!$constraint) {
- return [];
- }
- $seminar_id = $constraint['seminar_id'];
- $depth = $constraint['depth'] + 1;
-
- // count the entries and set correct page if necessary
- if ($with_childs) {
- $count_stmt = DBManager::get()->prepare("SELECT COUNT(*) FROM forum_entries
- LEFT JOIN forum_favorites as ou ON (ou.topic_id = forum_entries.topic_id AND ou.user_id = ?)
- WHERE (forum_entries.seminar_id = ?
- AND forum_entries.seminar_id != forum_entries.topic_id
- AND lft > ? AND rgt < ?) "
- . ($depth > 2 ? " OR forum_entries.topic_id = ". DBManager::get()->quote($parent_id) : '')
- . $add
- . " ORDER BY forum_entries.mkdate $sort_order");
- $count_stmt->execute([$GLOBALS['user']->id, $seminar_id, $constraint['lft'], $constraint['rgt']]);
- $count = $count_stmt->fetchColumn();
- } else {
- $count_stmt = DBManager::get()->prepare("SELECT COUNT(*) FROM forum_entries
- LEFT JOIN forum_favorites as ou ON (ou.topic_id = forum_entries.topic_id AND ou.user_id = ?)
- WHERE ((depth = ? AND forum_entries.seminar_id = ?
- AND forum_entries.seminar_id != forum_entries.topic_id
- AND lft > ? AND rgt < ?) "
- . ($depth > 2 ? " OR forum_entries.topic_id = ". DBManager::get()->quote($parent_id) : '')
- . ') '. $add
- . " ORDER BY forum_entries.mkdate $sort_order");
- $count_stmt->execute([$GLOBALS['user']->id, $depth, $seminar_id, $constraint['lft'], $constraint['rgt']]);
- $count = $count_stmt->fetchColumn();
- }
-
- // use the last page if the requested page does not exist
- if ($start > $count) {
- $page = ceil($count / ForumEntry::POSTINGS_PER_PAGE);
- ForumHelpers::setPage($page);
- $start = max(1, $page - 1) * ForumEntry::POSTINGS_PER_PAGE;
- }
-
- if ($with_childs) {
- $stmt = DBManager::get()->prepare("SELECT forum_entries.*, IF(ou.topic_id IS NOT NULL, 'fav', NULL) as fav
- FROM forum_entries
- LEFT JOIN forum_favorites as ou ON (ou.topic_id = forum_entries.topic_id AND ou.user_id = ?)
- WHERE (forum_entries.seminar_id = ?
- AND forum_entries.seminar_id != forum_entries.topic_id
- AND lft > ? AND rgt < ?) "
- . ($depth > 2 ? " OR forum_entries.topic_id = ". DBManager::get()->quote($parent_id) : '')
- . $add
- . " ORDER BY forum_entries.mkdate $sort_order"
- . ($limit ? " LIMIT $start, $limit" : ''));
- $stmt->execute([$GLOBALS['user']->id, $seminar_id, $constraint['lft'], $constraint['rgt']]);
- } else {
- $stmt = DBManager::get()->prepare("SELECT forum_entries.*, IF(ou.topic_id IS NOT NULL, 'fav', NULL) as fav
- FROM forum_entries
- LEFT JOIN forum_favorites as ou ON (ou.topic_id = forum_entries.topic_id AND ou.user_id = ?)
- WHERE ((depth = ? AND forum_entries.seminar_id = ?
- AND lft > ? AND rgt < ?) "
- . ($depth > 2 ? " OR forum_entries.topic_id = ". DBManager::get()->quote($parent_id) : '')
- . ') '. $add
- . " ORDER BY forum_entries.mkdate $sort_order"
- . ($limit ? " LIMIT $start, $limit" : ''));
- $stmt->execute([$GLOBALS['user']->id, $depth, $seminar_id, $constraint['lft'], $constraint['rgt']]);
- }
-
- if (!$stmt) {
- throw new Exception("Error while retrieving postings in " . __FILE__ . " on line " . __LINE__);
- }
-
- return ['list' => ForumEntry::parseEntries($stmt->fetchAll(PDO::FETCH_ASSOC)), 'count' => $count];
- }
-
-
- /**
- * Takes a posting-array like the one generated by ForumEntry::getList()
- * and adds the child-posting with the freshest creation-date to it.
- *
- * @param array $postings
- * @return array
- */
- public static function getLastPostings($postings)
- {
- foreach ($postings as $key => $posting)
- {
- $last_posting = [];
-
- if ($data = ForumEntry::getLatestPosting($posting['topic_id'])) {
- $last_posting['topic_id'] = $data['topic_id'];
- $last_posting['date'] = $data['mkdate'];
- $last_posting['user_id'] = $data['user_id'];
- $last_posting['user_fullname'] = $data['author'];
- $last_posting['username'] = get_username($data['user_id']);
- $last_posting['anonymous'] = $data['anonymous'];
-
- // we throw away all formatting stuff, tags, etc, so we have just the important bit of information
- $text = ForumEntry::removeQuotes($data['name']);
- $text = ForumEntry::br2space(ForumEntry::killFormat($text));
-
- if (mb_strlen($text) > 42) {
- $text = mb_substr($text, 0, 40) . '...';
- }
-
- $last_posting['text'] = $text;
- }
-
- $postings[$key]['last_posting'] = $last_posting;
- if (!$postings[$key]['last_unread'] = ForumEntry::getLastUnread($posting['topic_id'])) {
- $postings[$key]['last_unread'] = $last_posting['topic_id'] ?? '';
- }
- $postings[$key]['num_postings'] = ForumEntry::countEntries($posting['topic_id']);
-
- unset($last_posting);
- }
-
- return $postings;
- }
-
- /**
- * get a list of postings of a special type
- *
- * @param string $type one of 'area', 'list', 'postings', 'latest', 'favorites', 'dump', 'flat'
- * @param string $parent_id the are to fetch from
- * @return array array('list' => ..., 'count' => ...);
- */
- public static function getList($type, $parent_id)
- {
- $start = (ForumHelpers::getPage() - 1) * ForumEntry::POSTINGS_PER_PAGE;
- switch ($type) {
- case 'area':
- $list = ForumEntry::getEntries($parent_id, ForumEntry::WITHOUT_CHILDS, '', 'DESC', 0, 1000);
- $postings = $list['list'];
-
- $postings = ForumEntry::getLastPostings($postings);
- return ['list' => $postings, 'count' => $list['count']];
-
- case 'list':
- $constraint = ForumEntry::getConstraints($parent_id);
-
- if (!$constraint) {
- return [];
- }
-
- // purpose of the following query is to retrieve the threads
- // for an area ordered by the mkdate of their latest posting
- $stmt = DBManager::get()->prepare("SELECT SQL_CALC_FOUND_ROWS
- fe.*, IF(ou.topic_id IS NOT NULL, 'fav', NULL) as fav
- FROM forum_entries AS fe
- LEFT JOIN forum_favorites as ou ON (ou.topic_id = fe.topic_id AND ou.user_id = :user_id)
- WHERE fe.seminar_id = :seminar_id AND fe.lft > :left
- AND fe.rgt < :right AND fe.depth = 2
- ORDER BY sticky DESC, latest_chdate DESC
- LIMIT $start, ". ForumEntry::POSTINGS_PER_PAGE);
- $stmt->bindParam(':seminar_id', $constraint['seminar_id']);
- $stmt->bindParam(':left', $constraint['lft'], PDO::PARAM_INT);
- $stmt->bindParam(':right', $constraint['rgt'], PDO::PARAM_INT);
- $stmt->bindParam(':user_id', $GLOBALS['user']->id);
- $stmt->execute();
-
- $postings = $stmt->fetchAll(PDO::FETCH_ASSOC);
- $count = DBManager::get()->query("SELECT FOUND_ROWS()")->fetchColumn();
- $postings = ForumEntry::parseEntries($postings);
- $postings = ForumEntry::getLastPostings($postings);
-
- return ['list' => $postings, 'count' => $count];
-
- case 'postings':
- return ForumEntry::getEntries($parent_id, ForumEntry::WITH_CHILDS, '', 'ASC', $start);
-
- case 'newest':
- $constraint = ForumEntry::getConstraints($parent_id);
- if (!$constraint) {
- return [];
- }
- $last_visit_date = ForumVisit::getLastVisit($constraint['seminar_id']);
-
- // get postings
- $stmt = DBManager::get()->prepare("SELECT forum_entries.*, IF(ou.topic_id IS NOT NULL, 'fav', NULL) as fav
- FROM forum_entries
- LEFT JOIN forum_favorites as ou ON (ou.topic_id = forum_entries.topic_id AND ou.user_id = :user_id)
- WHERE seminar_id = :seminar_id AND lft > :left
- AND rgt < :right AND (mkdate >= :mkdate OR chdate >= :mkdate)
- ORDER BY mkdate ASC
- LIMIT $start, ". ForumEntry::POSTINGS_PER_PAGE);
-
- $stmt->bindParam(':seminar_id', $constraint['seminar_id']);
- $stmt->bindParam(':left', $constraint['lft']);
- $stmt->bindParam(':right', $constraint['rgt']);
- $stmt->bindParam(':mkdate', $last_visit_date);
- $stmt->bindParam(':user_id', $GLOBALS['user']->id);
- $stmt->execute();
-
- $postings = $stmt->fetchAll(PDO::FETCH_ASSOC);
-
- $postings = ForumEntry::parseEntries($postings);
-
- // count found postings
- $stmt_count = DBManager::get()->prepare("SELECT COUNT(*)
- FROM forum_entries
- WHERE seminar_id = :seminar_id AND lft > :left
- AND rgt < :right AND mkdate >= :mkdate
- ORDER BY mkdate ASC");
-
- $stmt_count->bindParam(':seminar_id', $constraint['seminar_id']);
- $stmt_count->bindParam(':left', $constraint['lft']);
- $stmt_count->bindParam(':right', $constraint['rgt']);
- $stmt_count->bindParam(':mkdate', $last_visit_date);
- $stmt_count->execute();
-
-
- // return results
- return ['list' => $postings, 'count' => $stmt_count->fetchColumn()];
-
- case 'latest':
- return ForumEntry::getEntries($parent_id, ForumEntry::WITH_CHILDS, 'AND depth > 1', 'DESC', $start);
-
- case 'favorites':
- $add = "AND ou.topic_id IS NOT NULL";
- return ForumEntry::getEntries($parent_id, ForumEntry::WITH_CHILDS, $add, 'DESC', $start);
-
- case 'dump':
- $constraint = ForumEntry::getConstraints($parent_id);
- if (!$constraint) {
- return [];
- }
- $seminar_id = $constraint['seminar_id'];
- $depth = $constraint['depth'] + 1;
-
- $stmt = DBManager::get()->prepare("SELECT * FROM forum_entries
- WHERE (forum_entries.seminar_id = ?
- AND forum_entries.seminar_id != forum_entries.topic_id
- AND lft > ? AND rgt < ?) "
- . ($depth > 2 ? " OR forum_entries.topic_id = ". DBManager::get()->quote($parent_id) : '')
- . " ORDER BY forum_entries.lft ASC");
- $stmt->execute([$seminar_id, $constraint['lft'], $constraint['rgt']]);
-
- return ForumEntry::parseEntries($stmt->fetchAll(PDO::FETCH_ASSOC));
-
- case 'flat':
- $constraint = ForumEntry::getConstraints($parent_id);
- if (!$constraint) {
- return [];
- }
- $stmt = DBManager::get()->prepare("SELECT SQL_CALC_FOUND_ROWS * FROM forum_entries
- WHERE lft > ? AND rgt < ? AND seminar_id = ? AND depth = ?
- ORDER BY name ASC");
- $stmt->execute([$constraint['lft'], $constraint['rgt'], $constraint['seminar_id'], $constraint['depth'] + 1]);
-
- $count = DBManager::get()->query("SELECT FOUND_ROWS()")->fetchColumn();
-
- $posting_list = [];
-
- // speed up things a bit by leaving out the formatReady fields
- foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $data) {
- // we throw away all formatting stuff, tags, etc, leaving the important bit of information
- $desc_short = ForumEntry::br2space(ForumEntry::killFormat($data['content']));
- if (mb_strlen($desc_short) > (ForumEntry::THREAD_PREVIEW_LENGTH + 2)) {
- $desc_short = mb_substr($desc_short, 0, ForumEntry::THREAD_PREVIEW_LENGTH) . '...';
- }
- $posting_list[$data['topic_id']] = [
- 'author' => $data['author'],
- 'topic_id' => $data['topic_id'],
- 'name_raw' => $data['name'],
- 'content_raw' => ForumEntry::killEdit($data['content']),
- 'content_short' => $desc_short,
- 'chdate' => $data['chdate'],
- 'mkdate' => $data['mkdate'],
- 'user_id' => $data['user_id'],
- 'raw_title' => $data['name'],
- 'raw_description' => ForumEntry::killEdit($data['content']),
- 'fav' => (!empty($data['fav']) && $data['fav'] == 'fav'),
- 'depth' => $data['depth'],
- 'seminar_id' => $data['seminar_id']
- ];
- }
-
- return ['list' => $posting_list, 'count' => $count];
-
- case 'depth_to_large':
- $constraint = ForumEntry::getConstraints($parent_id);
- if (!$constraint) {
- return [];
- }
- $stmt = DBManager::get()->prepare("SELECT SQL_CALC_FOUND_ROWS * FROM forum_entries
- WHERE lft > ? AND rgt < ? AND seminar_id = ? AND depth > 3
- ORDER BY name ASC");
- $stmt->execute([$constraint['lft'], $constraint['rgt'], $constraint['seminar_id']]);
-
- $count = DBManager::get()->query("SELECT FOUND_ROWS()")->fetchColumn();
-
- return ['list' => $stmt->fetchAll(PDO::FETCH_ASSOC), 'count' => $count];
- }
-
- throw new InvalidArgumentException("Invalid type {$type}");
- }
-
- /**
- * Get the latest forum entries for the passed entries childs
- *
- * @param string $parent_id
- * @param int $start_date timestamp
- * @param int $end_date timestamp
- *
- * @return array list of postings
- */
- public static function getLatestSince($parent_id, $start_date, $end_date)
- {
- $constraint = ForumEntry::getConstraints($parent_id);
- if (!$constraint) {
- return [];
- }
- $stmt = DBManager::get()->prepare("SELECT SQL_CALC_FOUND_ROWS * FROM forum_entries
- WHERE lft > ? AND rgt < ? AND seminar_id = ?
- AND mkdate BETWEEN ? AND ?
- ORDER BY name ASC");
- $stmt->execute([$constraint['lft'], $constraint['rgt'], $constraint['seminar_id'], $start_date, $end_date]);
-
- return $stmt->fetchAll(PDO::FETCH_ASSOC);
- }
-
- /**
- ** returns a list of postings for the passed search-term
- *
- * @param string $parent_id the area to search in (can be a whole seminar)
- * @param string $_searchfor the term to search for
- * @param array $options filter-options: search_title, search_content, search_author
- * @return array array('list' => ..., 'count' => ...);
- */
- public static function getSearchResults($parent_id, $_searchfor, $options)
- {
- $start = (ForumHelpers::getPage() - 1) * ForumEntry::POSTINGS_PER_PAGE;
-
- // if there are quoted parts, they should not be separated
- $suchmuster = '/".*"/U';
- preg_match_all($suchmuster, $_searchfor, $treffer);
- array_walk($treffer[0], function(&$value) { $value = trim($value, '"'); });
-
- // remove the quoted parts from $_searchfor
- $_searchfor = trim(preg_replace($suchmuster, '', $_searchfor));
-
- // split the searchstring $_searchfor at every space
- $parts = explode(' ', $_searchfor);
-
- foreach ($parts as $key => $val) {
- if ($val == '') {
- unset($parts[$key]);
- }
- }
-
- if (!empty($parts)) {
- $_searchfor = array_merge($parts, $treffer[0]);
- } else {
- $_searchfor = $treffer[0];
- }
-
- // make an SQL-statement out of the searchstring
- $search_string = [];
- foreach ($_searchfor as $key => $val) {
- if (!$val) {
- unset($_searchfor[$key]);
- } else {
- $search_word = '%'. $val .'%';
- $zw_search_string = [];
- if ($options['search_title']) {
- $zw_search_string[] = "name LIKE " . DBManager::get()->quote($search_word);
- }
-
- if ($options['search_content']) {
- $zw_search_string[] = "content LIKE " . DBManager::get()->quote($search_word);
- }
-
- if ($options['search_author']) {
- $zw_search_string[] = "(anonymous = 0 AND author LIKE " . DBManager::get()->quote($search_word) . ')';
- }
-
- if (!empty($zw_search_string)) {
- $search_string[] = '(' . implode(' OR ', $zw_search_string) . ')';
- }
- }
- }
-
- if (!empty($search_string)) {
- $add = "AND (" . implode(' AND ', $search_string) . ")";
- return array_merge(
- ['highlight' => $_searchfor],
- ForumEntry::getEntries($parent_id, ForumEntry::WITH_CHILDS, $add, 'DESC', $start)
- );
- }
-
- return ['num_postings' => 0, 'list' => []];
- }
-
- /**
- * returns the entry for the passed topic_id
- *
- * @param string $topic_id
- * @return array | false hash-array with the entries fields
- */
- public static function getEntry($topic_id)
- {
- return ForumEntry::getConstraints($topic_id);
- }
-
- /**
- * Count the number of child-elements that the passed entry has and return it.
- *
- * @param string $parent_id
- *
- * @return int the number of child entries for the passed entry
- */
- public static function countEntries($parent_id)
- {
- $data = ForumEntry::getConstraints($parent_id);
- if (!$data) {
- return 0;
- }
- return max((($data['rgt'] - $data['lft'] - 1) / 2) + 1, 0);
- }
-
- /**
- * Count the number of postings in a given course and return it.
- *
- * @param string $course_id the id of the given course
- *
- * @return int the number of postings in the course
- */
- public static function countPostings($course_id)
- {
- $stmt = DBManager::get()->prepare("SELECT COUNT(*) FROM forum_entries
- WHERE seminar_id = ? AND depth >= 2");
- $stmt->execute([$course_id]);
-
- return $stmt->fetchColumn(0);
- }
-
- /**
- * Count all entries the passed user has ever written and return the result
- *
- * @staticvar type $entries
- *
- * @param string $user_id
- *
- * @return int number of entries user has ever written
- */
- public static function countUserEntries($user_id, $seminar_id = null)
- {
- static $entries;
-
- if (empty($entries[$user_id])) {
- $stmt = DBManager::get()->prepare("SELECT COUNT(*)
- FROM forum_entries
- WHERE user_id = ? AND seminar_id = IFNULL(?, seminar_id)");
- $stmt->execute([$user_id, $seminar_id]);
-
- $entries[$user_id] = $stmt->fetchColumn();
- }
-
- return $entries[$user_id];
- }
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * * * * *
- * D A T A - C R E A T I O N *
- * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
-
- /**
- * insert a node into the table
- *
- * @param array $data an array containing the following fields:
- * topic_id the id of the new topic
- * seminar_id the id of the seminar to add the topic to
- * user_id the id of the user who created the topic
- * name the title of the entry
- * content the content of the entry
- * author the author's name as a plaintext string
- * author_host ip-address of creator
- * @param string $parent_id the node to add the topic to
- *
- * @return void
- */
- public static function insert($data, $parent_id)
- {
- $constraint = ForumEntry::getConstraints($parent_id);
-
- if (!$constraint) {
- return;
- }
-
- // #TODO: Zusammenfassen in eine Transaktion!!!
- DBManager::get()->exec('UPDATE forum_entries SET lft = lft + 2
- WHERE lft > '. $constraint['rgt'] ." AND seminar_id = '". $constraint['seminar_id'] ."'");
- DBManager::get()->exec('UPDATE forum_entries SET rgt = rgt + 2
- WHERE rgt >= '. $constraint['rgt'] ." AND seminar_id = '". $constraint['seminar_id'] ."'");
-
- $stmt = DBManager::get()->prepare("INSERT INTO forum_entries
- (topic_id, seminar_id, user_id, name, content, mkdate, latest_chdate,
- chdate, author, author_host, lft, rgt, depth, anonymous)
- VALUES (? ,?, ?, ?, ?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), ?, ?, ?, ?, ?, ?)");
- $stmt->execute([$data['topic_id'], $data['seminar_id'], $data['user_id'],
- $data['name'], $data['content'], $data['author'], $data['author_host'],
- $constraint['rgt'], $constraint['rgt'] + 1, $constraint['depth'] + 1, $data['anonymous'] ?? 0]);
-
- // update "latest_chdate" for easier sorting of actual threads
- DBManager::get()->exec("UPDATE forum_entries SET latest_chdate = UNIX_TIMESTAMP()
- WHERE topic_id = '" . $constraint['topic_id'] . "'");
-
- NotificationCenter::postNotification('ForumAfterInsert', $data['topic_id'], $data);
- }
-
-
- /**
- * update the passed topic
- *
- * @param string $topic_id the id of the topic to update
- * @param string $name the new name
- * @param string $content the new content
- *
- * @return void
- */
- public static function update($topic_id, $name, $content)
- {
- $post = ForumEntry::getConstraints($topic_id);
- if (!$post) {
- return;
- }
-
- if (time() - $post['mkdate'] > 5 * 60) {
- $content = ForumEntry::appendEdit($content);
- }
-
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET name = ?, content = ?, chdate = UNIX_TIMESTAMP(), latest_chdate = UNIX_TIMESTAMP()
- WHERE topic_id = ?");
- $stmt->execute([$name, $content, $topic_id]);
-
- // update "latest_chdate" for easier sorting of actual threads
- $parent_id = ForumEntry::getParentTopicId($topic_id);
- DBManager::get()->exec("UPDATE forum_entries SET latest_chdate = UNIX_TIMESTAMP()
- WHERE topic_id = '" . $parent_id . "'");
-
- $post['name'] = $name;
- $post['content'] = $content;
-
- NotificationCenter::postNotification('ForumAfterUpdate', $topic_id, $post);
- }
-
- /**
- * delete an entry and all his descendants from the mptt-table
- *
- * @param string $topic_id the id of the entry to delete
- *
- * @return void
- */
- public static function delete($topic_id)
- {
- $post = ForumEntry::getConstraints($topic_id);
-
- if ($post) {
- NotificationCenter::postNotification('ForumBeforeDelete', $topic_id, $post);
-
- // #TODO: Zusammenfassen in eine Transaktion!!!
- // get all entry-ids to delete them from the category-reference-table
- $stmt = DBManager::get()->prepare("SELECT topic_id FROM forum_entries
- WHERE seminar_id = ? AND lft >= ? AND rgt <= ? AND depth = 1");
- $stmt->execute([$post['seminar_id'], $post['lft'], $post['rgt']]);
- $ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
-
- if ($ids != false && !is_array($ids)) {
- $ids = [$ids];
- }
-
- if (!empty($ids)) {
- $stmt = DBManager::get()->prepare("DELETE FROM forum_categories_entries
- WHERE topic_id IN (:ids)");
- $stmt->bindParam(':ids', $ids, StudipPDO::PARAM_ARRAY);
- $stmt->execute();
- }
-
- // delete all entries
- $stmt = DBManager::get()->prepare("DELETE FROM forum_entries
- WHERE seminar_id = ? AND lft >= ? AND rgt <= ?");
-
- $stmt->execute([$post['seminar_id'], $post['lft'], $post['rgt']]);
-
- // update lft and rgt
- $diff = $post['rgt'] - $post['lft'] + 1;
- $stmt = DBManager::get()->prepare("UPDATE forum_entries SET lft = lft - $diff
- WHERE lft > ? AND seminar_id = ?");
- $stmt->execute([$post['rgt'], $post['seminar_id']]);
-
- $stmt = DBManager::get()->prepare("UPDATE forum_entries SET rgt = rgt - $diff
- WHERE rgt > ? AND seminar_id = ?");
- $stmt->execute([$post['rgt'], $post['seminar_id']]);
-
- }
-
- $parent = ForumEntry::getConstraints(ForumEntry::getParentTopicId($topic_id));
-
- if ($parent) {
- // set the latest_chdate to the latest child's chdate
- $stmt = DBManager::get()->prepare("SELECT chdate FROM forum_entries
- WHERE lft > ? AND rgt < ? AND seminar_id = ?
- ORDER BY chdate DESC LIMIT 1");
- $stmt->execute([$parent['lft'] ?? null, $parent['rgt'] ?? null, $parent['seminar_id'] ?? null]);
- $chdate = $stmt->fetchColumn();
-
- $stmt_insert = DBManager::get()->prepare("UPDATE forum_entries
- SET chdate = ? WHERE topic_id = ?");
- if ($chdate) {
- $stmt_insert->execute([$chdate, $parent['topic_id']]);
- } else {
- $stmt_insert->execute([$parent['chdate'], $parent['topic_id']]);
- }
- }
- }
-
- /**
- * move the passed topic to the passed area
- *
- * @param string $topic_id the topic to move
- * @param string $destination the area_id where the topic is moved to
- *
- * @return void
- */
- public static function move($topic_id, $destination)
- {
- // #TODO: Zusammenfassen in eine Transaktion!!!
- $constraints = ForumEntry::getConstraints($topic_id);
- if ($constraints) {
- // move the affected entries "outside" the tree
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET lft = lft * -1, rgt = rgt * -1
- WHERE seminar_id = ? AND lft >= ? AND rgt <= ?");
- $stmt->execute([$constraints['seminar_id'], $constraints['lft'], $constraints['rgt']]);
-
- // update the lft and rgt values of the parent to reflect the "deletion"
- $diff = $constraints['rgt'] - $constraints['lft'] + 1;
- $stmt = DBManager::get()->prepare("UPDATE forum_entries SET lft = lft - ?
- WHERE lft > ? AND seminar_id = ?");
- $stmt->execute([$diff, $constraints['rgt'], $constraints['seminar_id']]);
-
- $stmt = DBManager::get()->prepare("UPDATE forum_entries SET rgt = rgt - ?
- WHERE rgt > ? AND seminar_id = ?");
- $stmt->execute([$diff, $constraints['rgt'], $constraints['seminar_id']]);
-
- // make some space by updating the lft and rgt values of the target node
- $constraints_destination = ForumEntry::getConstraints($destination);
-
- if ($constraints_destination) {
- $size = $constraints['rgt'] - $constraints['lft'] + 1;
-
- $stmt = DBManager::get()->prepare("UPDATE forum_entries SET lft = lft + ?
- WHERE lft > ? AND seminar_id = ?");
- $stmt->execute([$size, $constraints_destination['rgt'], $constraints_destination['seminar_id']]);
-
- $stmt = DBManager::get()->prepare("UPDATE forum_entries SET rgt = rgt + ?
- WHERE rgt >= ? AND seminar_id = ?");
- $stmt->execute([$size, $constraints_destination['rgt'], $constraints_destination['seminar_id']]);
- }
- }
- //move the entries from "outside" the tree to the target node
- $constraints_destination = ForumEntry::getConstraints($destination);
-
- if ($constraints_destination) {
- // update the depth to reflect the new position in the tree
- // determine if we need to add, subtract or even do nothing to/from the depth
- $depth_mod = $constraints_destination['depth'] - $constraints['depth'] + 1;
-
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET depth = depth + ?
- WHERE seminar_id = ? AND lft < 0");
- $stmt->execute([$depth_mod, $constraints_destination['seminar_id']]);
-
- // if the depth is larger than 3, fix it
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET depth = 3
- WHERE seminar_id = ? AND depth > 3 AND lft < 0");
- $stmt->execute([$constraints_destination['seminar_id']]);
-
- // move the tree to its destination
- $diff = ($constraints_destination['rgt'] - ($constraints['rgt'] - $constraints['lft'])) - 1 - $constraints['lft'];
-
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET lft = (lft * -1) + ?, rgt = (rgt * -1) + ?
- WHERE seminar_id = ? AND lft < 0");
- $stmt->execute([$diff, $diff, $constraints_destination['seminar_id']]);
-
- if ($depth_mod != 0) {
- self::fix_ordering($topic_id);
- }
- }
- }
-
- private static function fix_ordering($parent_id)
- {
- $db = DBManager::get();
-
- $entry = ForumEntry::getConstraints($parent_id);
- if (!$entry) {
- return;
- }
-
- $stmt= $db->prepare('SELECT topic_id FROM forum_entries
- WHERE lft > ? AND rgt < ? AND depth = 3
- AND seminar_id = ?
- ORDER BY mkdate');
-
- $stmt->execute([$entry['lft'], $entry['rgt'], $entry['seminar_id']]);
-
- $lft = $entry['lft'] + 1;
- $rgt = $lft + 1;
-
- $inner_stmt = $db->prepare("UPDATE forum_entries SET lft=?, rgt=?
- WHERE topic_id = ?");
- while ($topic_id = $stmt->fetchColumn()) {
- $inner_stmt->execute([$lft, $rgt, $topic_id]);
-
- $lft += 2;
- $rgt += 2;
- }
- }
-
- /**
- * close the passed topic
- *
- * @param string $topic_id the topic to close
- *
- * @return void
- */
- public static function close($topic_id)
- {
- // close all entries belonging to the topic
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET closed = 1
- WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
- }
-
- /**
- * open the passed topic
- *
- * @param string $topic_id the topic to open
- *
- * @return void
- */
- public static function open($topic_id)
- {
- // open all entries belonging to the topic
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET closed = 0
- WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
- }
-
- /**
- * make the passed topic sticky
- *
- * @param string $topic_id the topic to make sticky
- *
- * @return void
- */
- public static function sticky($topic_id)
- {
- // open all entries belonging to the topic
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET sticky = 1
- WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
- }
-
- /**
- * make the passed topic unsticky
- *
- * @param string $topic_id the topic to make unsticky
- *
- * @return void
- */
- public static function unsticky($topic_id)
- {
- // open all entries belonging to the topic
- $stmt = DBManager::get()->prepare("UPDATE forum_entries
- SET sticky = 0
- WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
- }
-
- /**
- * check, if the default root-node for this seminar exists and make sure
- * the default category exists as well
- *
- * @param string $seminar_id
- *
- * @return void
- */
- public static function checkRootEntry($seminar_id)
- {
- setTempLanguage();
-
- // check, if the root entry in the topic tree exists
- $stmt = DBManager::get()->prepare("SELECT COUNT(*) FROM forum_entries
- WHERE topic_id = ? AND seminar_id = ?");
- $stmt->execute([$seminar_id, $seminar_id]);
- if ($stmt->fetchColumn() == 0) {
- $stmt = DBManager::get()->prepare("INSERT INTO forum_entries
- (topic_id, seminar_id, user_id, name, content, author, author_host, mkdate, chdate, lft, rgt, depth)
- VALUES (?, ?, '', 'Übersicht', '', '', '', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 1, 0)");
- $stmt->execute([$seminar_id, $seminar_id]);
- }
-
- // make sure, that the category "Allgemein" exists
- $stmt = DBManager::get()->prepare("INSERT IGNORE INTO forum_categories
- (category_id, seminar_id, entry_name) VALUES (?, ?, ?)");
- $stmt->execute([$seminar_id, $seminar_id, _('Allgemein')]);
-
- // make sure that the default area "Allgemeine Diskussionen" exists, if there is nothing else present
- $stmt = DBManager::get()->prepare("SELECT COUNT(*) FROM forum_entries
- WHERE seminar_id = ? AND depth = 1");
- $stmt->execute([$seminar_id]);
-
- // add default area
- if ($stmt->fetchColumn() == 0) {
- $data = [
- 'topic_id' => md5(uniqid()),
- 'seminar_id' => $seminar_id,
- 'user_id' => '',
- 'name' => _('Allgemeine Diskussion'),
- 'content' => _('Hier ist Raum für allgemeine Diskussionen'),
- 'author' => '',
- 'author_host' => ''
- ];
- ForumEntry::insert($data, $seminar_id);
- }
-
- restoreLanguage();
- }
-
- /**
- * returns the ten most active seminars
- *
- * @return array
- */
- public static function getTopTenSeminars()
- {
- return DBManager::get()->query("SELECT a.seminar_id, b.name AS display,
- count( a.seminar_id ) AS count FROM forum_entries a
- INNER JOIN seminare b USING ( seminar_id )
- WHERE b.visible = 1
- AND a.mkdate > UNIX_TIMESTAMP( NOW( ) - INTERVAL 2 WEEK )
- GROUP BY a.seminar_id
- ORDER BY count DESC
- LIMIT 10")->fetchAll(PDO::FETCH_ASSOC);
- }
-
- /**
- * count all entries that exists in the whole installation and return it.
- *
- * @return int
- */
- public static function countAllEntries()
- {
- return count_table_rows('forum_entries');
- }
-
- /**
- * updates the user-entries and replaces the old user-id by the new one
- *
- * @param string $user_from
- * @param string $user_to
- */
- public static function migrateUser($user_from, $user_to)
- {
- $stmt = DBManager::get()->prepare("UPDATE forum_entries SET user_id = ? WHERE user_id = ?");
- $stmt->execute([$user_to, $user_from]);
-
- $stmt = DBManager::get()->prepare("UPDATE IGNORE forum_favorites SET user_id = ? WHERE user_id = ?");
- $stmt->execute([$user_to, $user_from]);
-
- $stmt = DBManager::get()->prepare("UPDATE IGNORE forum_visits SET user_id = ? WHERE user_id = ?");
- $stmt->execute([$user_to, $user_from]);
-
- $stmt = DBManager::get()->prepare("UPDATE IGNORE forum_likes SET user_id = ? WHERE user_id = ?");
- $stmt->execute([$user_to, $user_from]);
-
- $stmt = DBManager::get()->prepare("UPDATE IGNORE forum_abo_users SET user_id = ? WHERE user_id = ?");
- $stmt->execute([$user_to, $user_from]);
- }
-
- /**
- * returns the complete seminar or only the passed sub-tree as a html-string
- *
- * @param string $seminar_id
- *
- * @return string
- */
- public static function getDump($seminar_id, $parent_id = null)
- {
- $seminar_name = get_object_name($seminar_id, 'sem');
- $content = '<h1>'. _('Forum') .': ' . $seminar_name['name'] .'</h1>';
- $data = ForumEntry::getList('dump', $parent_id ?: $seminar_id);
-
- foreach ($data as $entry) {
- if ($entry['depth'] == 1) {
- $content .= '<h2>'. _('Bereich') .': '. $entry['name'] .'</h2>';
- $content .= $entry['content'] .'<br><br>';
- } else if ($entry['depth'] == 2) {
- $content .= '<h3 style="margin-bottom: 0px;">'. _('Thema') .': '. $entry['name'] .'</h3>';
- $content .= '<i>' . sprintf(_('erstellt von %s am %s'), htmlReady($entry['author']),
- strftime('%A %d. %B %Y, %H:%M', (int)$entry['mkdate'])) . '</i><br>';
- $content .= $entry['content'] .'<br><br>';
- } else if ($entry['depth'] == 3) {
- $content .= '<b>'.$entry['name'] .'</b><br>';
- $content .= '<i>' . sprintf(_('erstellt von %s am %s'), htmlReady($entry['author']),
- strftime('%A %d. %B %Y, %H:%M', (int)$entry['mkdate'])) . '</i><br>';
- $content .= $entry['content'] .'<hr><br>';
- }
- }
-
- return $content;
- }
-
- public static function isClosed($topic_id)
- {
- foreach(ForumEntry::getPathToPosting($topic_id) as $entry) {
- if ($entry['closed']) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Export available data of a given user into a storage object
- * (an instance of the StoredUserData class) for that user.
- *
- * @param StoredUserData $storage object to store data into
- */
- public static function exportUserData(StoredUserData $storage)
- {
- $field_data = DBManager::get()->fetchAll("SELECT * FROM forum_entries WHERE user_id = ?", [$storage->user_id]);
- if ($field_data) {
- $storage->addTabularData(_('Forum Einträge'), 'forum_entries', $field_data);
- }
- }
-
-}
diff --git a/lib/classes/ForumFavorite.php b/lib/classes/ForumFavorite.php
deleted file mode 100644
index 9ffebf5..0000000
--- a/lib/classes/ForumFavorite.php
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-/**
- * ForumFavorite.php - Add and remove favorite postings
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumFavorite {
-
- /**
- * Set the topic denoted by the passed id as favorite for the
- * currently logged in user
- *
- * @param string $topic_id
- */
- static function set($topic_id) {
- $stmt = DBManager::get()->prepare("REPLACE INTO
- forum_favorites (topic_id, user_id)
- VALUES (?, ?)");
- $stmt->execute([$topic_id, $GLOBALS['user']->id]);
- }
-
- /**
- * Remove the topic denoted by the passed id as favorite for the
- * currently logged in user
- *
- * @param string $topic_id
- */
- static function remove($topic_id) {
- $stmt = DBManager::get()->prepare("DELETE FROM forum_favorites
- WHERE topic_id = ? AND user_id = ?");
- $stmt->execute([$topic_id, $GLOBALS['user']->id]);
- }
-} \ No newline at end of file
diff --git a/lib/classes/ForumHelpers.php b/lib/classes/ForumHelpers.php
deleted file mode 100644
index 0c036cf..0000000
--- a/lib/classes/ForumHelpers.php
+++ /dev/null
@@ -1,278 +0,0 @@
-<?php
-/**
- * ForumHelpers.php - Some useful helpers for the forum
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumHelpers {
-
- /**
- * The page for the current script run, modified by a global page-handle
- * @var int
- */
- static $page = 1;
-
- /**
- * helper_function for highlight($text, $highlight)
- *
- * @param string $text
- * @param array $highlight
- * @return string
- */
- public static function do_highlight($text, $highlight)
- {
- foreach ($highlight as $hl) {
- $text = preg_replace(
- '/' . preg_quote(htmlReady($hl), '/') . '/i',
- '<span class="highlight">$0</span>',
- $text
- );
- }
- return $text;
- }
-
- /**
- * This function highlights Text HTML-safe
- * (tags or words in tags are not highlighted, words between tags ARE highlighted)
- *
- * @param string $text the text where to words shall be highlighted, may contain tags
- * @param array $highlight an array of words to be highlighted
- * @return string the highlighted text
- */
- public static function highlight($text, $highlight)
- {
- if (empty($highlight)) {
- return $text;
- }
-
- $data = [];
- $treffer = [];
-
- // split text at every tag
- $pattern = '/<[^<]*>/U';
- preg_match_all($pattern, $text, $treffer, PREG_OFFSET_CAPTURE);
-
- if (sizeof($treffer[0]) == 0) {
- return self::do_highlight($text, $highlight);
- }
-
- // cycle trough the text between the tags and highlight all hits
- $last_pos = 0;
- foreach ($treffer[0] as $taginfo) {
- $size = mb_strlen($taginfo[0]);
- if ($taginfo[1] != 0) {
- $data[] = self::do_highlight(mb_substr($text, $last_pos, $taginfo[1] - $last_pos), $highlight);
- }
-
- $data[] = mb_substr($text, $taginfo[1], $size);
- $last_pos = $taginfo[1] + $size;
- }
-
- // don't miss the last portion of a posting
- if ($last_pos < mb_strlen($text)) {
- $data[] = self::do_highlight(mb_substr($text, $last_pos, mb_strlen($text) - $last_pos), $highlight);
- }
-
- return implode('', $data);
- }
-
- /**
- * Returns a human-readable version of the passed global Stud.IP permission.
- *
- * @param string $perm
- * @return string
- */
- public static function translate_perm($perm)
- {
- return match ($perm) {
- 'root' => _('Root'),
- 'admin' => _('Administrator/-in'),
- 'dozent' => _('Lehrende/-r'),
- 'tutor' => _('Tutor/-in'),
- 'autor' => _('Autor/-in'),
- 'user' => _('Leser/-in'),
- default => '',
- };
- }
-
- /**
- * return the currently chosen page
- *
- * @return int
- */
- public static function getPage()
- {
- return self::$page;
- }
-
- /**
- * set the current page
- *
- * @param int $page_num the page
- */
- public static function setPage($page_num)
- {
- self::$page = $page_num;
- }
-
- /**
- * Return an info-text explaining the visit-status of the passed topic_di
- * which has the passed number of new entries.
- *
- * @param string $num_entries the number of new entries
- * @param string $topic_id the id of the topic
- *
- * @return string a human readable, localized text
- */
- public static function getVisitText($num_entries, $topic_id)
- {
- if ($num_entries > 0) {
- $text = sprintf(_('Seit Ihrem letzten Besuch gibt es %s neue Beiträge'), $num_entries);
- } else {
- $all_entries = ForumEntry::countPostings($topic_id);
-
- if ($all_entries == 0) {
- $text = sprintf(_('Es gibt bisher keine Beiträge.'));
- } else if ($all_entries == 1) {
- $text = sprintf(_('Seit Ihrem letzten Besuch gab es nichts Neues.'
- . ' Es ist ein alter Beitrag vorhanden.'));
- } else {
- $text = sprintf(_('Seit Ihrem letzten Besuch gab es nichts Neues.'
- . ' Es sind %s alte Beiträge vorhanden.'), $all_entries);
- }
- }
-
- return $text;
- }
-
- /**
- * return the online status of the passed user, one of three possible
- * states is returned:
- * - available
- * - away
- * - offline
- *
- * @staticvar type $online_status
- *
- * @param string $user_id
- *
- * @return string
- */
- public static function getOnlineStatus($user_id)
- {
- static $online_status;
-
- // check if the corresponding user's profile is visible
- if (get_visibility_by_id($user_id) == false) {
- return 'offline';
- }
-
- if ($GLOBALS['user']->id == $user_id) {
- return 'available';
- }
-
- if (!$online_status) {
- $online_users = get_users_online(10);
- foreach ($online_users as $username => $data) {
- if ($data['last_action'] >= 300) {
- $online_status[$data['user_id']] = 'away';
- } else {
- $online_status[$data['user_id']] = 'available';
- }
- }
- }
-
- return $online_status[$user_id] ?? 'offline';
- }
-
- /**
- * Create a pdf of all postings belonging to the passed seminar located
- * under the passed topic_id. The PDF is dispatched automatically.
- *
- * BEWARE: This function never returns, it dies after the PDF has been
- * (succesfully or not) dispatched.
- *
- * @param string $seminar_id
- * @param string $parent_id
- */
- public static function createPdf($seminar_id, $parent_id = null)
- {
- $seminar_name = get_object_name($seminar_id, 'sem');
- $data = ForumEntry::getList('dump', $parent_id ?: $seminar_id);
- $first_page = true;
-
- $document = new ExportPDF();
- $document->SetTitle(_('Forum'));
- $document->setHeaderTitle(sprintf(_("Forum \"%s\""), $seminar_name['name']));
- $document->addPage();
-
- foreach ($data as $entry) {
- if (Config::get()->FORUM_ANONYMOUS_POSTINGS && $entry['anonymous']) {
- $author = _('anonym');
- } else {
- $author = $entry['author'];
- }
- if ($entry['depth'] == 1) {
- if (!$first_page) {
- $document->addPage();
- }
- $first_page = false;
- $document->addContent('!! '. _('Bereich') . ": {$entry['name_raw']}\n");
- $document->addContent($entry['content_raw']);
- $document->addContent("\n\n");
- } else if ($entry['depth'] == 2) {
- $document->addContent('! '. _('Thema') . ": {$entry['name_raw']}\n");
- $document->addContent('%%' . sprintf(_('erstellt von %s am %s'), $author,
- strftime('%A %d. %B %Y, %H:%M', (int)$entry['mkdate'])) . '%%' . "\n");
- $document->addContent($entry['content_raw']);
- $document->addContent("\n--\n");
- } else if ($entry['depth'] == 3) {
- $document->addContent("**{$entry['name_raw']}**\n");
- $document->addContent('%%' . sprintf(_('erstellt von %s am %s'), $author,
- strftime('%A %d. %B %Y, %H:%M', (int)$entry['mkdate'])) . '%%' . "\n");
- $document->addContent($entry['content_raw']);
- $document->addContent("\n--\n");
- }
- }
-
- $document->dispatch($seminar_name['name'] ." - Forum");
- die;
- }
-
-
- /**
- * Returns the id of the currently selected seminar or false, if no seminar
- * is selected
- *
- * @return mixed seminar_id or false
- */
- public static function getSeminarId()
- {
- return Context::getId();
- }
-
- /**
- * replace in the passed text every %%% with <% and every ### with %>
- * This is used to work around a limitation of the Button-API in combination
- * with the underscore.js way of inserting template vars.
- *
- * The Button-API correctly replaces < > with tags, but underscore.js is
- * unable to find them in their tag-represenation
- *
- * @param string $text the text to apply the replacements on
- *
- * @return string the modified text
- */
- public static function replace($text)
- {
- return str_replace('%%%', '<%', str_replace('###', '%>', $text));
- }
-}
diff --git a/lib/classes/ForumIssue.php b/lib/classes/ForumIssue.php
deleted file mode 100644
index 8be894f..0000000
--- a/lib/classes/ForumIssue.php
+++ /dev/null
@@ -1,96 +0,0 @@
-<?php
-/**
- * ForumIssue.php - Manage issues linked to postings
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumIssue
-{
- /**
- * Get the id of the topic linked to the issue denoted by the passed id.
- *
- * @param string $issue_id
- * @return string the id of the linked topic
- */
- static function getThreadIdForIssue($issue_id)
- {
- $stmt = DBManager::get()->prepare("SELECT topic_id FROM forum_entries_issues
- WHERE issue_id = ?");
- $stmt->execute([$issue_id]);
-
- return ($stmt->fetchColumn());
- }
-
-
- /**
- * Get the id of the issue linked to the topic denoted by the passed id.
- *
- * @param string $topic_id
- * @return string the id of the linked topic
- */
- static function getIssueIdForThread($topic_id)
- {
- $stmt = DBManager::get()->prepare("SELECT issue_id FROM forum_entries_issues
- WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
-
- return ($stmt->fetchColumn());
- }
-
-
- /**
- * Create/Update the linked posting for the passed issue_id
- *
- * @param string $seminar_id
- * @param string $issue_id issue id to link to
- * @param string $title (new) title of the posting
- * @param string $content (new) content of the posting
- */
- static function setThreadForIssue($seminar_id, $issue_id, $title, $content)
- {
- if ($topic_id = self::getThreadIdForIssue($issue_id)) { // update
- ForumEntry::update($topic_id, $title ?: _('Ohne Titel'), $content);
-
- } else { // create
- // make sure the forum is set up properly
- ForumEntry::checkRootEntry($seminar_id);
-
- $topic_id = md5(uniqid(rand()));
-
- ForumEntry::insert([
- 'topic_id' => $topic_id,
- 'seminar_id' => $seminar_id,
- 'user_id' => $GLOBALS['user']->id,
- 'name' => $title ?: _('Ohne Titel'),
- 'content' => $content,
- 'author' => get_fullname($GLOBALS['user']->id),
- 'author_host' => ($GLOBALS['user']->id == 'nobody') ? getenv('REMOTE_ADDR') : ''
- ], $seminar_id);
-
- $stmt = DBManager::get()->prepare("INSERT INTO forum_entries_issues
- (issue_id, topic_id) VALUES (?, ?)");
- $stmt->execute([$issue_id, $topic_id]);
- }
- }
-
- /**
- * Remove the link for the posting denoted by the passed topic_id
- *
- * @param object $notification
- * @param string $topic_id
- */
- static function unlinkIssue($notification, $topic_id)
- {
- $stmt = DBManager::get()->prepare("DELETE FROM forum_entries_issues
- WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
- }
-}
diff --git a/lib/classes/ForumLike.php b/lib/classes/ForumLike.php
deleted file mode 100644
index ad83274..0000000
--- a/lib/classes/ForumLike.php
+++ /dev/null
@@ -1,105 +0,0 @@
-<?php
-/**
- * ForumLike.php - Manage the likes for postings
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumLike {
-
- /**
- * Set the posting denoted by the passed topic_id as liked for the
- * currently logged in user
- *
- * @param string $topic_id
- */
- public static function like($topic_id)
- {
- $stmt = DBManager::get()->prepare("REPLACE INTO
- forum_likes (topic_id, user_id)
- VALUES (?, ?)");
- $stmt->execute([$topic_id, $GLOBALS['user']->id]);
-
- // get posting owner
- $data = ForumEntry::getConstraints($topic_id);
- if (!$data) {
- return;
- }
-
- // notify owner of posting about the like
- setTempLanguage($data['user_id']);
- $notification = sprintf(_('%s gefällt einer deiner Forenbeiträge!'), $GLOBALS['user']->getFullName());
- restoreLanguage();
-
- PersonalNotifications::add(
- $data['user_id'],
- URLHelper::getURL('dispatch.php/course/forum/index/index/' . $topic_id .'?highlight_topic='. $topic_id .'#'. $topic_id),
- $notification,
- $topic_id,
- Icon::create('forum')
- );
- }
-
- /**
- * Revoke the liking of the posting denoted by the passed topic_id for the
- * currently logged in user
- *
- * @param string $topic_id
- */
- static function dislike($topic_id) {
- $stmt = DBManager::get()->prepare("DELETE FROM forum_likes
- WHERE topic_id = ? AND user_id = ?");
- $stmt->execute([$topic_id, $GLOBALS['user']->id]);
- }
-
- /**
- * Get the user_id for all likers of the topic denoted by the passed id
- *
- * @param string $topic_id
- * @return array an array of user_id's
- */
- static function getLikes($topic_id) {
- $stmt = DBManager::get()->prepare("SELECT
- auth_user_md5.user_id FROM forum_likes
- LEFT JOIN auth_user_md5 USING (user_id)
- LEFT JOIN user_info USING (user_id)
- WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
-
- return $stmt->fetchAll(PDO::FETCH_COLUMN);
- }
-
- /**
- * count the number of likes the user has received - system-wide
- *
- * @staticvar type $entries
- * @param string $user_id the user's id to count the received likes for
- *
- * @return int the number of likes received
- */
- static function receivedForUser($user_id)
- {
- static $entries;
-
- if (empty($entries[$user_id])) {
- $stmt = DBManager::get()->prepare("SELECT COUNT(*)
- FROM forum_entries
- LEFT JOIN forum_likes USING (topic_id)
- WHERE forum_entries.user_id = ?
- AND forum_likes.topic_id IS NOT NULL
- AND forum_likes.user_id != ?");
- $stmt->execute([$user_id, $user_id]);
-
- $entries[$user_id] = $stmt->fetchColumn();
- }
-
- return $entries[$user_id];
- }
-}
diff --git a/lib/classes/ForumPerm.php b/lib/classes/ForumPerm.php
deleted file mode 100644
index 2832978..0000000
--- a/lib/classes/ForumPerm.php
+++ /dev/null
@@ -1,217 +0,0 @@
-<?php
-/**
- * filename - Short description for file
- *
- * Long description for file (if any)...
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumPerm {
-
- /**
- * Check, if the a user has the passed permission in a seminar.
- * Possible permissions are:
- * edit_category - Editing the name of a category<br>
- * add_category - Adding a new category<br>
- * remove_category - Removing an existing category<br>
- * sort_category - Sorting categories<br>
- * edit_area - Editing an area (title + content)<br>
- * add_area - Adding a new area<br>
- * remove_area - Removing an area and all belonging threads<br>
- * sort_area - Sorting of areas in categories and between categories<br>
- * search - Searching in postings<br>
- * edit_entry - Editing of foreign threads/postings<br>
- * add_entry - Creating a new thread/posting<br>
- * remove_entry - Removing of foreign threads/postings<br>
- * fav_entry - Marking a Posting as "favorite"<br>
- * like_entry - Liking a posting<br>
- * move_thread - Moving a thrad between ares<br>
- * close_thread - Close or open a thread<br>
- * make_sticky - Make a thread sticky<br>
- * abo - Signing up for mail-notifications for new entries<br>
- * forward_entry - Forwarding an existing entry as a message<br>
- * pdfexport - Exporting parts of the forum as PDF<br>
- * admin - Allowed to mass-administrate the forum<br>
- * view - Allowed to view the forum at all<br>
- * edit_closed - Editing entries in a closed thread
- *
- * @param string $perm one of the modular permissions
- * @param string $seminar_id the seminar to check for
- * @param string $user_id the user to check for
- * @return boolean true, if the user has the perms, false otherwise
- */
- public static function has($perm, $seminar_id, $user_id = null)
- {
- static $permissions = [];
-
- // if no user-id is passed, use the current user (for your convenience)
- if (!$user_id) {
- $user_id = $GLOBALS['user']->id;
- }
-
- // get the status for the user in the passed seminar
- if (empty($permissions[$seminar_id][$user_id])) {
- $permissions[$seminar_id][$user_id] = $GLOBALS['perm']->get_studip_perm($seminar_id, $user_id);
- }
-
- $status = $permissions[$seminar_id][$user_id];
-
- // take care of the not logged in user
- if ($user_id == 'nobody' || $status == false) {
- // which status has nobody - read only or read/write?
- if (get_object_type($seminar_id) == 'sem') {
- $course = Course::find($seminar_id);
-
- if ($course->schreibzugriff == 0) {
- $status = 'nobody_write';
- } else if ($course->lesezugriff == 0) {
- $status = 'nobody_read';
- } else {
- return false;
- }
- } else {
- return false;
- }
- }
-
- // root and admins have all possible perms
- if (in_array($status, words('root admin')) !== false) {
- return true;
- }
-
- // eCULT Notlösung
- if ($status == 'tutor' && $seminar_id == '30e0b89dcc9173d5fccf9f22b13b87bd') {
- $status = 'autor';
- }
-
- // check the status and the passed permission
- if (($status == 'dozent' || $status == 'tutor') && in_array($perm,
- words('edit_category add_category remove_category sort_category '
- . 'edit_area add_area remove_area sort_area '
- . 'search edit_entry add_entry remove_entry fav_entry like_entry move_thread '
- . 'make_sticky close_thread abo forward_entry pdfexport view edit_closed')
- ) !== false) {
- return true;
- } else if ($status == 'autor' && in_array($perm, words('search add_entry fav_entry like_entry forward_entry abo pdfexport view')) !== false) {
- return true;
- } else if ($status == 'user' && in_array($perm, words('search forward_entry pdfexport view')) !== false) {
- return true;
- } else if ($status == 'nobody_write' && in_array($perm, words('search add_entry pdfexport view')) !== false) {
- return true;
- } else if ($status == 'nobody_read' && in_array($perm, words('search pdfexport view')) !== false) {
- return true;
- }
-
- // user has no permission
- return false;
- }
-
- /**
- * If the user has not the passed perm in a seminar, an AccessDeniedException
- * is thrown.
- * An optional topic_id can be passed which is checked against the passed
- * seminar if the topic_id belongs to that seminar
- *
- * @param string $perm for the list of possible perms and their function see @ForumPerm::hasPerm()
- * @param string $seminar_id the seminar to check for
- * @param string $topic_id if passed, this topic_id is checked if it belongs to the passed seminar
- *
- * @throws AccessDeniedException
- */
- public static function check($perm, $seminar_id, $topic_id = null)
- {
- if (!self::has($perm, $seminar_id)) {
- throw new AccessDeniedException(sprintf(
- _("Sie haben keine Berechtigung für diese Aktion! Benötigte Berechtigung: %s"),
- $perm)
- );
- }
-
- // check the topic id (if any)
- if ($topic_id) {
- self::checkTopicId($seminar_id, $topic_id);
- }
- }
-
- /**
- * Check if the current user is allowed to edit the topic
- * denoted by the passed id
- *
- * @staticvar array $perms
- *
- * @param string $topic_id the id for the topic to check for
- *
- * @return bool true if the user has the necessary perms, false otherwise
- */
- public static function hasEditPerms($topic_id)
- {
- static $perms = [];
-
- if (empty($perms[$topic_id])) {
- // find out if the posting is the last in the thread
- $constraints = ForumEntry::getConstraints($topic_id);
- if (!$constraints) {
- return false;
- }
- $stmt = DBManager::get()->prepare("SELECT user_id, seminar_id
- FROM forum_entries WHERE topic_id = ?");
- $stmt->execute([$topic_id]);
-
- $data = $stmt->fetch();
-
- $closed = ForumEntry::isClosed($topic_id);
-
- $perms[$topic_id] = (($GLOBALS['user']->id == $data['user_id'] && $GLOBALS['user']->id != 'nobody') ||
- ForumPerm::has('edit_entry', $constraints['seminar_id']))
- && (!$closed || $closed && ForumPerm::has('edit_closed', $constraints['seminar_id']));
- }
-
- return $perms[$topic_id];
- }
-
- /**
- * check if the passed category_id belongs to the passed seminar_id.
- * Throws an AccessDenied denied exception if this is not the case
- *
- * @param string $seminar_id id of the seminar, the category should belong to
- * @param string $category_id the id of the category to check
- */
- public static function checkCategoryId($seminar_id, $category_id)
- {
- $data = ForumCat::get($category_id);
-
- if ($data['seminar_id'] != $seminar_id) {
- throw new AccessDeniedException(sprintf(
- _('Forum: Sie haben keine Berechtigung auf die Kategorie mit der ID %s zuzugreifen!'),
- $category_id
- ));
- }
- }
-
- /**
- * check if the passed topic_id belongs to the passed seminar_id.
- * Throws an AccessDenied denied exception if this is not the case
- *
- * @param string $seminar_id id of the seminar, the category should belong to
- * @param string $topic_id the id of the topic to check
- */
- public static function checkTopicId($seminar_id, $topic_id)
- {
- $data = ForumEntry::getConstraints($topic_id);
-
- if (!$data || $data['seminar_id'] !== $seminar_id) {
- throw new AccessDeniedException(sprintf(
- _('Forum: Sie haben keine Berechtigung auf den Eintrag mit der ID %s zuzugreifen!'),
- $topic_id
- ));
- }
- }
-}
diff --git a/lib/classes/ForumVisit.php b/lib/classes/ForumVisit.php
deleted file mode 100644
index 08535b2..0000000
--- a/lib/classes/ForumVisit.php
+++ /dev/null
@@ -1,171 +0,0 @@
-<?php
-/**
- * ForumVisit - Functions for visit-dates for threads
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-class ForumVisit {
-
- /**
- * This is the maximum number of seconds that unread entries are
- * marked as new.
- */
- const LAST_VISIT_MAX = 7776000; // 90 days
-
- /**
- * return number of new entries since last visit up to 3 month ago
- *
- * @param string $parent_id the seminar_id for the entries
- * @param string $visitdate count all entries newer than this timestamp
- *
- * @return int the number of entries
- */
- static function getCount($parent_id, $visitdate)
- {
- if ($visitdate < time() - ForumVisit::LAST_VISIT_MAX) {
- $visitdate = time() - ForumVisit::LAST_VISIT_MAX;
- }
-
- $constraints = ForumEntry::getConstraints($parent_id);
-
- if (!$constraints) {
- return 0;
- }
-
- $query = "SELECT COUNT(*)
- FROM forum_entries
- WHERE lft >= :lft
- AND rgt <= :rgt
- AND user_id != :user_id
- AND (user_id != '' OR author != '')
- AND seminar_id = :seminar_id
- AND topic_id != seminar_id
- AND chdate > :lastvisit";
- $stmt = DBManager::get()->prepare($query);
-
- $stmt->bindValue(':user_id', $GLOBALS['user']->id);
- $stmt->bindValue(':lft', $constraints['lft']);
- $stmt->bindValue(':rgt', $constraints['rgt']);
- $stmt->bindValue(':seminar_id', $constraints['seminar_id']);
- $stmt->bindValue(':lastvisit', $visitdate);
-
- $stmt->execute();
-
- return $stmt->fetchColumn();
- }
-
- /**
- * Set the seminar denoted by the passed id as visited by the currently
- * logged in user
- *
- * @param string $seminar_id
- */
- static function setVisit($seminar_id) {
- $type = get_object_type($seminar_id, words('fak inst sem'));
- if ($type === 'fak') {
- $type = 'inst';
- }
- if (self::getVisit($seminar_id) < object_get_visit($seminar_id, $type, false, false)) {
- self::setVisitdates($seminar_id);
- }
- }
-
- /**
- * Stores the visitdate in last_visitdate and sets the current time for as new visitdate
- *
- * @param string $seminar_id the seminar that has been entered
- */
- static function setVisitdates($seminar_id) {
- $stmt = DBManager::get()->prepare('SELECT visitdate FROM forum_visits
- WHERE user_id = ? AND seminar_id = ?');
- $stmt->execute([$GLOBALS['user']->id, $seminar_id]);
- $visitdate = $stmt->fetchColumn();
-
- $stmt = DBManager::get()->prepare("REPLACE INTO forum_visits
- (user_id, seminar_id, visitdate, last_visitdate)
- VALUES (?, ?, UNIX_TIMESTAMP(), ?)");
- $stmt->execute([$GLOBALS['user']->id, $seminar_id, $visitdate]);
-
- }
-
-
- /**
- * returns visitdate and last_visitdate for the passed seminar and the
- * currently logged in user
- *
- * @staticvar array $visit
- *
- * @param string $seminar_id the seminar to fetch the visitdates for
- * @return mixed an array containing visitdate and last_visitdate
- */
- private static function getVisitDates($seminar_id)
- {
- static $visit = [];
-
- // no costly checking for root or nobody necessary
- if ($GLOBALS['perm']->have_perm('root') || $GLOBALS['user']->id == 'nobody') {
- $tstamp = mktime(23, 59, 00, date('m'), 31, date('y'));
- return ['visit' => $tstamp, 'last_visitdate' => $tstamp];
- }
-
- if (!isset($visit[$seminar_id])) {
- $visit[$seminar_id] = [];
- }
- if (!isset($visit[$seminar_id][$GLOBALS['user']->id])) {
- $stmt = DBManager::get()->prepare("SELECT visitdate, last_visitdate FROM forum_visits
- WHERE seminar_id = ? AND user_id = ?");
- $stmt->execute([$seminar_id, $GLOBALS['user']->id]);
- $visit[$seminar_id][$GLOBALS['user']->id] = $stmt->fetch(PDO::FETCH_ASSOC);
-
- // no entry for this seminar yet present
- if (!$visit[$seminar_id][$GLOBALS['user']->id]) {
- // set visitdate to current time
- $visit[$seminar_id][$GLOBALS['user']->id] = [
- 'visit' => time() - ForumVisit::LAST_VISIT_MAX,
- 'last_visitdate' => time() - ForumVisit::LAST_VISIT_MAX
- ];
- }
-
- // prevent visit-dates from being older than LAST_VISIT_MAX allows
- foreach ($visit[$seminar_id][$GLOBALS['user']->id] as $type => $date) {
- if ($date < time() - ForumVisit::LAST_VISIT_MAX) {
- $visit[$seminar_id][$GLOBALS['user']->id][$type] = time() - ForumVisit::LAST_VISIT_MAX;
- }
- }
- }
-
- return $visit[$seminar_id][$GLOBALS['user']->id];
- }
-
- /**
- * return the last_visitdate for the passed seminar and currently logged in user
- *
- * @param string $seminar_id the seminar to get the last_visitdate for
- * @return int a timestamp
- */
- static function getLastVisit($seminar_id)
- {
- $visit = self::getVisitDates($seminar_id);
- return $visit['last_visitdate'];
- }
-
- /**
- * return the visitdate for the passed seminar and currently logged in user
- *
- * @param string $seminar_id the seminar to get the visitdate for
- * @return int a timestamp
- */
- static function getVisit($seminar_id)
- {
- $visit = self::getVisitDates($seminar_id);
- return $visit['visitdate'] ?? 0;
- }
-}
diff --git a/lib/classes/JsonApi/Models/ForumCat.php b/lib/classes/JsonApi/Models/ForumCat.php
deleted file mode 100644
index 25a0207..0000000
--- a/lib/classes/JsonApi/Models/ForumCat.php
+++ /dev/null
@@ -1,46 +0,0 @@
-<?php
-
-namespace JsonApi\Models;
-
-/**
- * @property string category_id string primary key
- * @property string seminar_id string foreign key
- * @property string entry_name string database_column
- * @property string pos int
- */
-class ForumCat extends \SimpleORMap
-{
- protected static function configure($config = array())
- {
- $config['db_table'] = 'forum_categories';
-
- $config['belongs_to']['course'] = array(
- 'class_name' => 'Course',
- 'foreign_key' => 'course_id',
- );
- parent::configure($config);
- }
-
- public static function getCategories(\Course $course)
- {
- return self::findBySQL('seminar_id = ? ORDER BY pos ASC', [$course->id]);
- }
-
- public function deleteCategory($categoryId, $seminarId)
- {
- //delete category...
- $stmt = \DBManager::get()->prepare('DELETE FROM
- forum_categories
- WHERE category_id = ?');
- $stmt->execute(array($categoryId));
-
- //... and set all it's entries to default category
- $stmt2 = \DBManager::get()->prepare('UPDATE
- forum_categories_entries
- SET category_id = ?, pos = 999
- WHERE category_id = ?');
- $stmt2->execute(array($seminarId, $categoryId));
-
- return $stmt && $stmt2;
- }
-}
diff --git a/lib/classes/JsonApi/Models/ForumEntry.php b/lib/classes/JsonApi/Models/ForumEntry.php
deleted file mode 100644
index e52cc3e..0000000
--- a/lib/classes/JsonApi/Models/ForumEntry.php
+++ /dev/null
@@ -1,146 +0,0 @@
-<?php
-
-namespace JsonApi\Models;
-
-/**
- * @property string $category_id primary key
- * @property string $seminar_id foreign key
- * @property string $entry_name database_column
- * @property string $pos
- */
-
-class ForumEntry extends \SimpleORMap
-{
- public static function getCatFromEntry($topicId)
- {
- $targetEntry = ForumEntry::find($topicId);
- $parentEntries = ForumEntry::findBySQL(
- 'forum_entries.lft <= ? AND forum_entries.rgt >= ? AND forum_entries.seminar_id = ? AND forum_entries.depth = 1 ORDER BY forum_entries.lft ASC',
- [(int) $targetEntry['lft'], (int) $targetEntry['rgt'], $targetEntry['seminar_id']]
- );
-
- if (count($parentEntries) > 0) {
- $category = ForumCat::findOneBySQL(
- 'LEFT JOIN forum_categories_entries AS fce USING (category_id)
- WHERE fce.topic_id = ?',
- [$parentEntries[0]->id]
- );
- }
-
- if (empty($category)) {
- $category = ForumCat::findOneBySql(
- "seminar_id = ? AND category_id = seminar_id",
- [$targetEntry->seminar_id]
- );
- }
-
- return $category;
- }
-
- public static function getCategories($seminarId, $asObjects = false)
- {
- $stmt = \DBManager::get()->prepare('SELECT * FROM forum_categories
- WHERE seminar_id = ? ORDER BY pos ASC');
- $stmt->execute([$seminarId]);
- $ret = $stmt->fetchGrouped(\PDO::FETCH_ASSOC);
-
- return $asObjects ? ForumCat::getCatObjects($ret) : $ret;
- }
-
- public static function getEntriesFromCat(ForumCat $targetCategory)
- {
- return ForumEntry::findBySQL(
- 'LEFT JOIN forum_categories_entries '
- .'ON forum_categories_entries.topic_id = forum_entries.topic_id '
- .'WHERE forum_categories_entries.category_id = ? '
- .'ORDER BY forum_entries.lft DESC',
- [$targetCategory->id]
- );
- }
-
- public static function getChildEntries($topicId)
- {
- $targetEntry = ForumEntry::find($topicId);
-
- return ForumEntry::findBySQL(
- 'lft > ? AND rgt < ? AND seminar_id = ? ',
- [$targetEntry->lft, $targetEntry->rgt, $targetEntry->seminar_id]
- );
- }
-
- public function storeWith(\SimpleORMap $parent, ForumEntry $entry)
- {
- if ($this->is_new) {
- if ($parent instanceof \JsonApi\Models\ForumCat) {
- $stmt = \DBManager::get()->prepare('INSERT INTO forum_categories_entries
- (category_id, topic_id)
- VALUES (?, ?)');
- $stmt->execute([$parent->id, $this->id]);
- $constraint = $entry;
- } elseif ($parent instanceof \JsonApi\Models\ForumEntry) {
- $constraint = ForumEntry::find($parent->id);
- }
-
- if (is_null($constraint)) {
- throw new \InvalidArgumentException('There must be a parent');
- }
-
- \DBManager::get()->exec('UPDATE forum_entries SET lft = lft + 2
- WHERE lft > '.$constraint['rgt']." AND seminar_id = '".$constraint['seminar_id']."'");
- \DBManager::get()->exec('UPDATE forum_entries SET rgt = rgt + 2
- WHERE rgt >= '.$constraint['rgt']." AND seminar_id = '".$constraint['seminar_id']."'");
-
- $this->lft = $constraint['rgt'];
- $this->rgt = $constraint['rgt'] + 1;
- $this->depth = $constraint['depth'] + 1;
-
- \NotificationCenter::postNotification('ForumAfterInsert', $this->topic_id, $this->toArray());
-
- // update "latest_chdate" for easier sorting of actual threads
- \DBManager::get()->exec("UPDATE forum_entries SET latest_chdate = UNIX_TIMESTAMP()
- WHERE topic_id = '".$constraint['topic_id']."'");
- }
-
- return $this->store();
- }
-
- public function deleteEntry($topicId)
- {
- $targetEntry = ForumEntry::find($topicId);
- $childEntries = ForumEntry::getChildEntries($topicId);
-
- //delete an entry and his appended entries...
- $stmt_one = \DBManager::get()->prepare('DELETE FROM forum_entries
- WHERE seminar_id = ? AND lft >= ? AND rgt <= ?');
- $stmt_one->execute([$targetEntry->seminar_id, $targetEntry->lft, $targetEntry->rgt]);
-
- //...and update affected entries
- $diff = $targetEntry->lft - $targetEntry->rgt;
- $stmt_two = \DBManager::get()->prepare("UPDATE forum_entries SET lft = lft - ${diff}
- WHERE lft > ? AND seminar_id = ?");
- $stmt_two->execute([$targetEntry->rgt, $targetEntry->seminar_id]);
-
- $stmt_three = \DBManager::get()->prepare("UPDATE forum_entries SET rgt = rgt - ${diff}
- WHERE rgt > ? AND seminar_id = ?");
- $stmt_three->execute([$targetEntry->rgt, $targetEntry->seminar_id]);
-
- //... delete categories_entries-row if exists
-
- $stmt_four = \DBManager::get()->prepare('DELETE FROM forum_categories_entries
- WHERE topic_id = ?');
- $stmt_four->execute([$topicId]);
-
- return $stmt_one && $stmt_two && $stmt_three && $stmt_four;
- }
-
- protected static function configure($config = [])
- {
- $config['db_table'] = 'forum_entries';
-
- $config['belongs_to']['category'] = [
- 'class_name' => '\JsonApi\Models\ForumEntry',
- 'assoc_func' => 'getCatFromEntry',
- ];
- parent::configure($config);
- }
-}
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 86c6d92..4df0677 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -671,24 +671,56 @@ class RouteMap
private function addAuthenticatedForumRoutes(RouteCollectorProxy $group): void
{
- $group->get('/courses/{id}/forum-categories', Routes\Forum\ForumCategoriesIndex::class);
-
- $group->get('/forum-entries/{id}', Routes\Forum\ForumEntriesShow::class);
- $group->get('/forum-entries/{id}/entries', Routes\Forum\ForumEntryEntriesIndex::class);
-
- $group->get('/forum-categories/{id}', Routes\Forum\ForumCategoriesShow::class);
-
- $group->get('/forum-categories/{id}/entries', Routes\Forum\ForumCategoryEntriesIndex::class);
-
- $group->post('/forum-entries/{id}/entries', Routes\Forum\ForumEntryEntriesCreate::class);
- $group->post('/forum-categories/{id}/entries', Routes\Forum\ForumCategoryEntriesCreate::class);
- $group->post('/courses/{id}/forum-categories', Routes\Forum\ForumCategoriesCreate::class);
-
- $group->patch('/forum-categories/{id}', Routes\Forum\ForumCategoriesUpdate::class);
- $group->patch('/forum-entries/{id}', Routes\Forum\ForumEntriesUpdate::class);
-
- $group->delete('/forum-categories/{id}', Routes\Forum\ForumCategoriesDelete::class);
- $group->delete('/forum-entries/{id}', Routes\Forum\ForumEntriesDelete::class);
+ $group->group('/courses/{course_id}', function ($forum) {
+ $forum->get('/forum-configs', Routes\Forum\ForumConfigIndex::class);
+ $forum->get('/forum-categories', Routes\Forum\ForumCategoryIndex::class);
+ $forum->get('/forum-discussions', Routes\Forum\ForumDiscussionIndex::class);
+ $forum->get('/forum-topics', Routes\Forum\ForumTopicIndex::class);
+ $forum->get('/forum-subscriptions', Routes\Forum\ForumSubscriptionIndex::class);
+ });
+
+ $group->group('/forum-subscriptions', function ($forum) {
+ $forum->post('', Routes\Forum\ForumSubscriptionStore::class);
+ $forum->get('/{subscription_id}', Routes\Forum\ForumSubscriptionShow::class);
+ $forum->delete('/{subscription_id}', Routes\Forum\ForumSubscriptionDelete::class);
+ });
+
+ $group->group('/forum-topics', function ($forum) {
+ $forum->get('/{topic_id}', Routes\Forum\ForumTopicShow::class);
+ $forum->get('/{topic_id}/discussions', Routes\Forum\ForumTopicDiscussions::class);
+ $forum->patch('/sort', Routes\Forum\ForumTopicUpdateSort::class);
+ });
+
+ $group->group('/forum-categories', function ($forum) {
+ $forum->get('/{category_id}', Routes\Forum\ForumCategoryShow::class);
+ $forum->get('/{category_id}/topics', Routes\Forum\ForumCategoryTopics::class);
+ $forum->patch('/sort', Routes\Forum\ForumCategoryUpdateSort::class);
+ });
+
+ $group->group('/forum-discussion-types', function ($forum) {
+ $forum->get('', Routes\Forum\ForumDiscussionTypeIndex::class);
+ $forum->get('/{type_id}', Routes\Forum\ForumDiscussionTypeShow::class);
+ $forum->get('/{type_id}/discussions', Routes\Forum\ForumDiscussionTypeDiscussions::class);
+ });
+
+ $group->group('/forum-discussions', function ($forum) {
+ $forum->get('/{discussion_id}', Routes\Forum\ForumDiscussionShow::class);
+ $forum->get('/{discussion_id}/postings', Routes\Forum\ForumDiscussionPostings::class);
+ });
+
+ $group->group('/forum-postings', function ($forum) {
+ $forum->post('', Routes\Forum\ForumPostingStore::class);
+ $forum->get('/{posting_id}', Routes\Forum\ForumPostingShow::class);
+ $forum->get('/{posting_id}/reactions', Routes\Forum\ForumPostingReactions::class);
+ $forum->patch('/{posting_id}', Routes\Forum\ForumPostingUpdate::class);
+ $forum->delete('/{posting_id}', Routes\Forum\ForumPostingDelete::class);
+ });
+
+ $group->group('/forum-posting-reactions', function ($forum) {
+ $forum->post('', Routes\Forum\ForumPostingReactionStore::class);
+ $forum->get('/{reaction_id}', Routes\Forum\ForumPostingReactionShow::class);
+ $forum->delete('/{reaction_id}', Routes\Forum\ForumPostingReactionDelete::class);
+ });
}
private function addAuthenticatedStockImagesRoutes(RouteCollectorProxy $group): void
diff --git a/lib/classes/JsonApi/Routes/Forum/AbstractEntriesCreate.php b/lib/classes/JsonApi/Routes/Forum/AbstractEntriesCreate.php
deleted file mode 100644
index 734b9d8..0000000
--- a/lib/classes/JsonApi/Routes/Forum/AbstractEntriesCreate.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use JsonApi\JsonApiController;
-use JsonApi\Routes\ValidationTrait;
-use JsonApi\Models\ForumEntry;
-use JsonApi\Models\ForumCat;
-
-abstract class AbstractEntriesCreate extends JsonApiController
-{
- use ValidationTrait;
-
- protected function validateResourceDocument($json, $data)
- {
- $content = self::arrayHas($json, 'data.attributes.title');
- if (empty($content)) {
- return 'Entries should not be empty.';
- }
- }
-
- protected function createEntryFromJSON($user, $parentId, $json)
- {
- //Check whether the parent is category or entry of first or seccond depth
- $title = self::arrayGet($json, 'data.attributes.title');
- $content = self::arrayGet($json, 'data.attributes.content');
- $content = \Studip\Markup::purifyHtml($content);
- $parent = $this->getParentObject($parentId);
-
- return $this->createEntry($title, $content, $parent, $user);
- }
-
- protected function getParentObject($parentId)
- {
- if ($parent = ForumCat::find($parentId)) {
- return $parent;
- }
-
- return ForumEntry::find($parentId);
- }
-
- protected function createEntry($title, $content, $parent, $user)
- {
- //Do we create id's like this?
- $topicId = md5(uniqid(rand()));
- if (empty($title)) {
- $title = $parent->name;
- }
- $data = [
- 'topic_id' => $topicId,
- 'seminar_id' => $parent->seminar_id,
- 'user_id' => $user->id,
- 'name' => $title,
- 'content' => $content,
- 'author' => $user->getFullName(),
- 'anonymous' => 0,
- ];
-
- $entry = new ForumEntry();
- $entry->setData($data);
- $entry->storeWith($parent, $entry);
-
- return $entry;
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumAuthority.php b/lib/classes/JsonApi/Routes/Forum/ForumAuthority.php
deleted file mode 100644
index acc5d2a..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumAuthority.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use JsonApi\Models\ForumEntry;
-
-class ForumAuthority
-{
- public static function has(\User $user, $perm, \Course $course, ForumEntry $topic = null)
- {
- if (!\ForumPerm::has($perm, $course->id, $user->id)) {
- return false;
- }
-
- if ($topic) {
- try {
- \ForumPerm::checkTopicId($course->id, $topic->id);
- } catch (\AccessDeniedException $e) {
- return false;
- }
- }
-
- return true;
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesCreate.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoriesCreate.php
deleted file mode 100644
index 280e437..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesCreate.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use Psr\Http\Message\ServerRequestInterface as Request;
-use Psr\Http\Message\ResponseInterface as Response;
-use JsonApi\Errors\AuthorizationFailedException;
-use JsonApi\Errors\InternalServerError;
-use JsonApi\Errors\RecordNotFoundException;
-use JsonApi\JsonApiController;
-use JsonApi\Routes\ValidationTrait;
-use JsonApi\Models\ForumCat;
-
-/**
- * Create a Forum-Category by a given course-id.
- */
-class ForumCategoriesCreate extends JsonApiController
-{
- use ValidationTrait;
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameters)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- $json = $this->validate($request);
-
- if (!$course = \Course::find($args['id'])) {
- throw new RecordNotFoundException();
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
- if (!$category = $this->createCategoryFromJSON($course->id, $json)) {
- throw new InternalServerError('Could not create the category.');
- }
-
- return $this->getCreatedResponse($category);
- }
-
- protected function createCategoryFromJSON($courseId, $json)
- {
- $title = self::arrayGet($json, 'data.attributes.title');
-
- $category = new ForumCat();
- $category->seminar_id = $courseId;
- $category->entry_name = $title;
- $category->store();
-
- return $category;
- }
-
- protected function validateResourceDocument($json, $data)
- {
- $title = self::arrayGet($json, 'data.attributes.title', '');
- if (empty($title)) {
- return 'Categorys must have a title. ';
- }
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesDelete.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoriesDelete.php
deleted file mode 100644
index 2441a1f..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesDelete.php
+++ /dev/null
@@ -1,42 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use JsonApi\Errors\AuthorizationFailedException;
-use JsonApi\Errors\RecordNotFoundException;
-use JsonApi\JsonApiController;
-use JsonApi\Models\ForumCat;
-use Psr\Http\Message\ResponseInterface as Response;
-use Psr\Http\Message\ServerRequestInterface as Request;
-
-/**
- * Löscht eine Forum-Kategorie.
- */
-class ForumCategoriesDelete extends JsonApiController
-{
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- if (!$category = ForumCat::find($args['id'])) {
- throw new RecordNotFoundException();
- }
- if (!$course = \Course::find($category->seminar_id)) {
- throw new RecordNotFoundException('Course does not exist.');
- }
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
- if (!$this->deleteCategory($category)) {
- throw new RecordNotFoundException();
- }
-
- return $this->getCodeResponse(204);
- }
-
- protected static function deleteCategory($category)
- {
- return $category->deleteCategory($category->category_id, $category->seminar_id);
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesShow.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoriesShow.php
deleted file mode 100644
index 190efb2..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesShow.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-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 ForumCategoriesShow extends JsonApiController
-{
- protected $allowedIncludePaths = ['course', 'entries'];
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- if (!$category = \JsonApi\Models\ForumCat::find($args['id'])) {
- throw new RecordNotFoundException('could not find category');
- }
- if (!$course = \Course::find($category->seminar_id)) {
- throw new RecordNotFoundException('could not find course');
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
-
- return $this->getContentResponse($category);
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesUpdate.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoriesUpdate.php
deleted file mode 100644
index e09339e..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesUpdate.php
+++ /dev/null
@@ -1,63 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use JsonApi\Errors\AuthorizationFailedException;
-use JsonApi\Errors\RecordNotFoundException;
-use JsonApi\Errors\InternalServerError;
-use JsonApi\JsonApiController;
-use JsonApi\Routes\ValidationTrait;
-use Psr\Http\Message\ResponseInterface as Response;
-use Psr\Http\Message\ServerRequestInterface as Request;
-use JsonApi\Models\ForumCat;
-
-/**
- * Edits content of a news.
- */
-class ForumCategoriesUpdate extends JsonApiController
-{
- use ValidationTrait;
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- $json = $this->validate($request);
-
- if (!$category = ForumCat::find($args['id'])) {
- throw new RecordNotFoundException('Category has not been found.');
- }
- if (!$course = \Course::find($category->seminar_id)) {
- throw new RecordNotFoundException('Course does not exist.');
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
- if (!$category = $this->updateCategoryFromJSON($category, $json)) {
- throw new InternalServerError('Could not update the category.');
- }
-
- return $this->getContentResponse($category);
- }
-
- protected function updateCategoryFromJSON($category, $json)
- {
- $title = self::arrayGet($json, 'data.attributes.title');
- $category->entry_name = $title;
- if ($category->isDirty()) {
- $category->store();
-
- return $category;
- }
- }
-
- protected function validateResourceDocument($json, $data)
- {
- $title = self::arrayGet($json, 'data.attributes.title', '');
- if (empty($title)) {
- return 'Categories must have a title. ';
- }
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesCreate.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesCreate.php
deleted file mode 100644
index 7199e39..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesCreate.php
+++ /dev/null
@@ -1,43 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use Psr\Http\Message\ServerRequestInterface as Request;
-use Psr\Http\Message\ResponseInterface as Response;
-use JsonApi\Errors\AuthorizationFailedException;
-use JsonApi\Errors\RecordNotFoundException;
-use JsonApi\Errors\InternalServerError;
-use JsonApi\Models\ForumCat;
-
-class ForumCategoryEntriesCreate extends AbstractEntriesCreate
-{
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- $json = $this->validate($request);
- $categoryId = $args['id'];
-
- if (!ForumCat::exists($categoryId)) {
- throw new RecordNotFoundException('Could not find category.');
- }
-
- $courseId = ForumCat::find($categoryId)->seminar_id;
- $course = \Course::find($courseId);
-
- if (!$course) {
- throw new RecordNotFoundException('Could not find course.');
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
-
- if (!$entry = $this->createEntryFromJSON($this->getUser($request), $categoryId, $json)) {
- throw new InternalServerError('Could not create forum entry.');
- }
-
- return $this->getCreatedResponse($entry);
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesIndex.php
deleted file mode 100644
index 1ddc4c0..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumCategoryEntriesIndex.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-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\Models\ForumEntry;
-use JsonApi\Models\ForumCat;
-
-class ForumCategoryEntriesIndex extends JsonApiController
-{
- protected $allowedIncludePaths = ['category', 'entries'];
-
- protected $allowedPagingParameters = ['offset', 'limit'];
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- if (!$category = ForumCat::find($args['id'])) {
- throw new RecordNotFoundException('Could not find category.');
- }
- if (!$course = \Course::find($category->seminar_id)) {
- throw new RecordNotFoundException('Could not find course.');
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
-
- $entries = ForumEntry::getEntriesFromCat($category);
-
- list($offset, $limit) = $this->getOffsetAndLimit();
-
- return $this->getPaginatedContentResponse(
- array_slice($entries, $offset, $limit),
- count($entries)
- );
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoryIndex.php
index ea5d17d..9e8375c 100644
--- a/lib/classes/JsonApi/Routes/Forum/ForumCategoriesIndex.php
+++ b/lib/classes/JsonApi/Routes/Forum/ForumCategoryIndex.php
@@ -1,43 +1,36 @@
<?php
-
namespace JsonApi\Routes\Forum;
-use Psr\Http\Message\ServerRequestInterface as Request;
-use Psr\Http\Message\ResponseInterface as Response;
use JsonApi\Errors\AuthorizationFailedException;
use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
use JsonApi\JsonApiController;
+use Forum\ForumCategory;
-/**
- * Displays all data to a special Forum category.
- */
-class ForumCategoriesIndex extends JsonApiController
+class ForumCategoryIndex extends JsonApiController
{
- protected $allowedIncludePaths = ['course', 'entries'];
-
protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumCategory::REL_TOPICS
+ ];
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
public function __invoke(Request $request, Response $response, $args)
{
- if (!$course = \Course::find($args['id'])) {
+ if (!$course = \Course::find($args['course_id'])) {
throw new RecordNotFoundException();
}
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
throw new AuthorizationFailedException();
}
- if (!$categories = \JsonApi\Models\ForumCat::getCategories($course)) {
- throw new RecordNotFoundException();
- }
-
- list($offset, $limit) = $this->getOffsetAndLimit();
+ $categories = ForumCategory::findBySQL("range_id = ? ORDER BY position ASC, mkdate DESC", [$course->id]);
return $this->getPaginatedContentResponse(
- array_slice($categories, $offset, $limit),
+ array_slice($categories, ...$this->getOffsetAndLimit()),
count($categories)
);
}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoryShow.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoryShow.php
new file mode 100644
index 0000000..46bba9d
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumCategoryShow.php
@@ -0,0 +1,36 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumCategoryShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumCategory::REL_TOPICS
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $category = \Forum\ForumCategory::find($args['category_id']);
+
+ if (!$category) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($category->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ return $this->getContentResponse($category);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoryTopics.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoryTopics.php
new file mode 100644
index 0000000..f597c52
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumCategoryTopics.php
@@ -0,0 +1,46 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use Forum\ForumCategory;
+use Forum\ForumSubscription;
+use Forum\ForumTopic;
+
+class ForumCategoryTopics extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumCategory::REL_TOPICS
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $category = \Forum\ForumCategory::find($args['category_id']);
+
+ if (!$category) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($category->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $topics = $category->topics ?? \SimpleORMapCollection::createFromArray([]);
+
+ return $this->getPaginatedContentResponse(
+ $topics->limit(...$this->getOffsetAndLimit()),
+ count($topics)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumCategoryUpdateSort.php b/lib/classes/JsonApi/Routes/Forum/ForumCategoryUpdateSort.php
new file mode 100644
index 0000000..3e2c597
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumCategoryUpdateSort.php
@@ -0,0 +1,62 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Forum\ForumCategory;
+
+class ForumCategoryUpdateSort extends JsonApiController
+{
+ use ValidationTrait;
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $json = $this->validate($request);
+ $course_id = self::arrayGet($json, 'data.relationships.range.data.id');
+
+ if (!$course = \Course::find($course_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!\CoreForum::isModerator($course->id)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $category_ids = self::arrayGet($json, 'data.attributes.category-ids');
+
+ ForumCategory::findEachBySQL(
+ function (ForumCategory $category) use ($category_ids) {
+ $category->position = (int) array_search($category->category_id, $category_ids);
+ $category->store();
+ },
+ "category_id IN (:category_ids) AND range_id = :course_id",
+ [
+ "category_ids" => $category_ids,
+ "course_id" => $course->id
+ ]
+ );
+
+ return $this->getCodeResponse(204);
+ }
+
+ protected function validateResourceDocument($json, $data)
+ {
+ $required_keys = [
+ 'data.attributes.category-ids' => 'Missing `data.attributes.category-ids`',
+ 'data.relationships.range.data.id' => 'Missing `data.relationships.range.data.id`',
+ ];
+
+ foreach ($required_keys as $key => $error_message) {
+ if (!self::arrayHas($json, $key)) {
+ return $error_message;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumConfigIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumConfigIndex.php
new file mode 100644
index 0000000..d3be0ce
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumConfigIndex.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumConfigIndex extends JsonApiController
+{
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ if (!$course = \Course::find($args['course_id'])) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ return $this->getMetaResponse([
+ 'is-admin' => \CoreForum::isAdmin($course->id),
+ 'is-moderator' => \CoreForum::isModerator($course->id),
+ 'anonymous-post' => (bool) \Config::get()->FORUM_ANONYMOUS_POSTINGS,
+ 'tile-layout' => (bool) \UserConfig::get($user->user_id)->FORUM_TILE_LAYOUT
+ ]);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumDiscussionIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionIndex.php
new file mode 100644
index 0000000..c8e8477
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionIndex.php
@@ -0,0 +1,60 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumDiscussion;
+use Forum\ForumPosting;
+
+class ForumDiscussionIndex extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedFilteringParameters = ['last-visit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumCategory::REL_TOPICS,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_CATEGORY,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_DISCUSSION_TYPE,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_MEMBERS,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_TAGS
+ ];
+
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ if (!$course = \Course::find($args['course_id'])) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+ $last_visit = $filtering['last-visit'] ?? 0;
+
+ if ($last_visit) {
+ $recent_posts = ForumPosting::getRecentPosts($course->id, $last_visit);
+ $discussions = ForumDiscussion::findBySQL(
+ "discussion_id IN (:discussion_ids)",
+ [
+ 'discussion_ids' => array_column($recent_posts, 'discussion_id')
+ ]
+ );
+ } else {
+ $discussions = ForumDiscussion::findBySQL(
+ "JOIN forum_topics USING(topic_id) WHERE forum_topics.range_id = :course_id ORDER BY position ASC, mkdate DESC",
+ ['course_id' => $course->id]
+ );
+ }
+
+ return $this->getPaginatedContentResponse(
+ array_slice($discussions, ...$this->getOffsetAndLimit()),
+ count($discussions)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumDiscussionPostings.php b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionPostings.php
new file mode 100644
index 0000000..320344d
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionPostings.php
@@ -0,0 +1,51 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumPosting;
+use Forum\ForumPostingRead;
+
+class ForumDiscussionPostings extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumPosting::REL_DISCUSSION,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_POSTING,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_OPENGRAPH_URLS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_AUTHOR,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS_USER
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $discussion = \Forum\ForumDiscussion::find($args['discussion_id']);
+
+ if (!$discussion) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($discussion->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $postings = ForumPosting::findBySQL("discussion_id = :discussion_id ORDER BY mkdate ASC", ['discussion_id' => $discussion->discussion_id]);
+
+ ForumPostingRead::updateUserReadPoint($user->user_id, $discussion->discussion_id, count($postings));
+
+ return $this->getPaginatedContentResponse(
+ array_slice($postings, ...$this->getOffsetAndLimit()),
+ count($postings)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumDiscussionShow.php b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionShow.php
new file mode 100644
index 0000000..784a181
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionShow.php
@@ -0,0 +1,39 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumDiscussionShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_POSTINGS,
+ \JsonApi\Schemas\Forum\ForumCategory::REL_TOPICS,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_CATEGORY,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_DISCUSSION_TYPE
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $discussion = \Forum\ForumDiscussion::find($args['discussion_id']);
+
+ if (!$discussion) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($discussion->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ return $this->getContentResponse($discussion);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeDiscussions.php b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeDiscussions.php
new file mode 100644
index 0000000..162f23a
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeDiscussions.php
@@ -0,0 +1,31 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumDiscussionTypeDiscussions extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumDiscussionType::REL_DISCUSSIONS
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $discussion_type = \Forum\ForumDiscussionType::find($args['type_id']);
+
+ if (!$discussion_type) {
+ throw new RecordNotFoundException();
+ }
+
+ $discussions = $discussion_type->discussions ?? \SimpleORMapCollection::createFromArray([]);
+
+ return $this->getPaginatedContentResponse(
+ $discussions->limit(...$this->getOffsetAndLimit()),
+ count($discussions)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeIndex.php
new file mode 100644
index 0000000..b6a9999
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeIndex.php
@@ -0,0 +1,25 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\BadRequestException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumDiscussionTypeIndex extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumDiscussionType::REL_DISCUSSIONS
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $discussion_types = \Forum\ForumDiscussionType::findBySQL('1');
+
+ return $this->getPaginatedContentResponse(
+ array_slice($discussion_types, ...$this->getOffsetAndLimit()),
+ count($discussion_types)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeShow.php b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeShow.php
new file mode 100644
index 0000000..66ff9e5
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumDiscussionTypeShow.php
@@ -0,0 +1,25 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumDiscussionTypeShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumDiscussionType::REL_DISCUSSIONS
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $discussion_type = \Forum\ForumDiscussionType::find($args['type_id']);
+
+ if (!$discussion_type) {
+ throw new RecordNotFoundException();
+ }
+
+ return $this->getContentResponse($discussion_type);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumEntriesDelete.php b/lib/classes/JsonApi/Routes/Forum/ForumEntriesDelete.php
deleted file mode 100644
index 41de02d..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumEntriesDelete.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-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\Models\ForumEntry;
-
-/**
- * Löscht eine Forum-Kategorie.
- */
-class ForumEntriesDelete extends JsonApiController
-{
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- if (!$entry = ForumEntry::find($args['id'])) {
- throw new RecordNotFoundException();
- }
- if (!$course = \Course::find($entry->seminar_id)) {
- throw new RecordNotFoundException('could not find course');
- }
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
- if (!$entry->deleteEntry($entry->topic_id)) {
- throw new RecordNotFoundException();
- }
-
- return $this->getCodeResponse(204);
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumEntriesShow.php b/lib/classes/JsonApi/Routes/Forum/ForumEntriesShow.php
deleted file mode 100644
index 12d1034..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumEntriesShow.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-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\Models\ForumEntry;
-
-class ForumEntriesShow extends JsonApiController
-{
- protected $allowedIncludePaths = ['entries'];
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- if (!$entry = ForumEntry::find($args['id'])) {
- throw new RecordNotFoundException('Could not find entry.');
- }
- if (!$course = \Course::find($entry->seminar_id)) {
- throw new RecordNotFoundException('Could not find course.');
- }
- if (!ForumAuthority::has($this->getUser($request), 'view', $course, $entry)) {
- throw new AuthorizationFailedException();
- }
-
- return $this->getContentResponse($entry);
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumEntriesUpdate.php b/lib/classes/JsonApi/Routes/Forum/ForumEntriesUpdate.php
deleted file mode 100644
index 21eeba4..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumEntriesUpdate.php
+++ /dev/null
@@ -1,72 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use JsonApi\Errors\AuthorizationFailedException;
-use JsonApi\Errors\RecordNotFoundException;
-use JsonApi\Errors\InternalServerError;
-use JsonApi\JsonApiController;
-use JsonApi\Routes\ValidationTrait;
-use Psr\Http\Message\ResponseInterface as Response;
-use Psr\Http\Message\ServerRequestInterface as Request;
-use JsonApi\Models\ForumEntry;
-
-/**
- * Edits content of a news.
- */
-class ForumEntriesUpdate extends JsonApiController
-{
- use ValidationTrait;
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- $json = $this->validate($request);
-
- if (!$entry = ForumEntry::find($args['id'])) {
- throw new RecordNotFoundException('Entry has not been found.');
- }
- if (!$course = \Course::find($entry->seminar_id)) {
- throw new RecordNotFoundException('Course does not exist.');
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
- if (!$entry = $this->updateEntryFromJSON($entry, $json)) {
- throw new InternalServerError('Could not update the entry.');
- }
-
- return $this->getContentResponse($entry);
- }
-
- protected function updateEntryFromJSON($entry, $json)
- {
- $title = self::arrayGet($json, 'data.attributes.title');
- $content = self::arrayGet($json, 'data.attributes.content');
- if (!empty($title)) {
- $entry->name = $title;
- }
- if (!empty($content)) {
- $content = \Studip\Markup::purifyHtml($content);
- $entry->content = $content;
- }
- if ($entry->isDirty()) {
- $entry->store();
- }
-
- return $entry;
- }
-
- protected function validateResourceDocument($json, $data)
- {
- $title = self::arrayGet($json, 'data.attributes.title');
- $content = self::arrayGet($json, 'data.attributes.content');
-
- if (empty($title) && empty($content)) {
- return 'You must change entry-data to update.';
- }
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesCreate.php b/lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesCreate.php
deleted file mode 100644
index d3562b5..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesCreate.php
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-use Psr\Http\Message\ServerRequestInterface as Request;
-use Psr\Http\Message\ResponseInterface as Response;
-use JsonApi\Errors\AuthorizationFailedException;
-use JsonApi\Errors\RecordNotFoundException;
-use JsonApi\Errors\InternalServerError;
-use JsonApi\Models\ForumEntry;
-
-class ForumEntryEntriesCreate extends AbstractEntriesCreate
-{
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- $json = $this->validate($request);
- $user = $this->getUser($request);
-
- if (!$related = ForumEntry::find($args['id'])) {
- throw new RecordNotFoundException();
- }
- if (!$course = \Course::find($related->seminar_id)) {
- throw new RecordNotFoundException('Could not find course.');
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course)) {
- throw new AuthorizationFailedException();
- }
- if (!$entry = $this->createEntryFromJSON($user, $related->id, $json)) {
- throw new InternalServerError('could not create forum-entry');
- }
-
- return $this->getCreatedResponse($entry);
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesIndex.php
deleted file mode 100644
index 5b03e99..0000000
--- a/lib/classes/JsonApi/Routes/Forum/ForumEntryEntriesIndex.php
+++ /dev/null
@@ -1,45 +0,0 @@
-<?php
-
-namespace JsonApi\Routes\Forum;
-
-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\Models\ForumEntry;
-
-class ForumEntryEntriesIndex extends JsonApiController
-{
- protected $allowedIncludePaths = ['entries', 'category'];
-
- protected $allowedPagingParameters = ['offset', 'limit'];
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function __invoke(Request $request, Response $response, $args)
- {
- if (!$entry = ForumEntry::find($args['id'])) {
- throw new RecordNotFoundException('Could not find entry.');
- }
-
- if (!$course = \Course::find($entry->seminar_id)) {
- throw new RecordNotFoundException('could not find course');
- }
-
- if (!ForumAuthority::has($this->getUser($request), 'view', $course, $entry)) {
- throw new AuthorizationFailedException();
- }
-
- if (!$entries = ForumEntry::getChildEntries($entry->id)) {
- throw new RecordNotFoundException('could not find forum-entries');
- }
-
- list($offset, $limit) = $this->getOffsetAndLimit();
- $total = count($entries);
- $data = array_slice($entries, $offset, $limit);
-
- return $this->getPaginatedContentResponse($data, $total);
- }
-}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingDelete.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingDelete.php
new file mode 100644
index 0000000..9929b14
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingDelete.php
@@ -0,0 +1,38 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumPosting;
+
+class ForumPostingDelete extends JsonApiController
+{
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $user = $this->getUser($request);
+
+ $posting = ForumPosting::findOneBySQL(
+ "posting_id = :posting_id AND user_id = :user_id",
+ [
+ 'posting_id' => $args['posting_id'],
+ 'user_id' => $user->user_id
+ ]
+ );
+
+ if (!$posting) {
+ throw new RecordNotFoundException();
+ }
+
+ if ($posting->discussion->closed_at) {
+ throw new AuthorizationFailedException();
+ }
+
+ $posting->delete();
+
+ return $this->getCodeResponse(204);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionDelete.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionDelete.php
new file mode 100644
index 0000000..6e5ff35
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionDelete.php
@@ -0,0 +1,32 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumPostingReaction;
+
+class ForumPostingReactionDelete extends JsonApiController
+{
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $user = $this->getUser($request);
+
+ $posting_reaction = ForumPostingReaction::findOneBySQL(
+ "id = :reaction_id AND user_id = :user_id",
+ [
+ 'reaction_id' => $args['reaction_id'],
+ 'user_id' => $user->user_id
+ ]
+ );
+
+ if (!$posting_reaction) {
+ throw new RecordNotFoundException();
+ }
+
+ $posting_reaction->delete();
+
+ return $this->getCodeResponse(204);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionShow.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionShow.php
new file mode 100644
index 0000000..a6a0299
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionShow.php
@@ -0,0 +1,26 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumPostingReactionShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumPostingReaction::REL_POSTING,
+ \JsonApi\Schemas\Forum\ForumPostingReaction::REL_USER,
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $posting_reaction = \Forum\ForumPostingReaction::find($args['reaction_id']);
+
+ if (!$posting_reaction) {
+ throw new RecordNotFoundException();
+ }
+
+ return $this->getContentResponse($posting_reaction);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionStore.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionStore.php
new file mode 100644
index 0000000..58b7a8b
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactionStore.php
@@ -0,0 +1,77 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Forum\ForumPosting;
+use Forum\ForumPostingReaction;
+
+class ForumPostingReactionStore extends JsonApiController
+{
+ use ValidationTrait;
+
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumPostingReaction::REL_USER
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $json = $this->validate($request);
+ $user = $this->getUser($request);
+
+ $posting = ForumPosting::find(self::arrayGet($json, 'data.relationships.posting.data.id'));
+
+ if (!$posting) {
+ throw new BadRequestException();
+ }
+
+ if (!$course = \Course::find($posting->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $posting_reaction = ForumPostingReaction::create([
+ 'posting_id' => $posting->posting_id,
+ 'user_id' => $user->user_id,
+ 'emoji' => self::arrayGet($json, 'data.attributes.emoji')
+ ]);
+
+ if ($user->user_id !== $posting->user_id) {
+ \PersonalNotifications::add(
+ $posting->user_id,
+ \URLHelper::getURL('dispatch.php/course/forum/discussions/show/'.$posting->discussion_id, ['cid' => $posting->range_id], true)."#post_" . $posting->posting_id,
+ sprintf(_("%s hat auf deinen Beitrag reagiert."), $user->getFullName()),
+ null,
+ self::arrayGet($json, 'data.meta.emoji-icon')
+ );
+ }
+
+ return $this->getCreatedResponse($posting_reaction);
+ }
+
+ protected function validateResourceDocument($json, $data)
+ {
+ $required_keys = [
+ 'data.attributes.emoji' => 'Missing `data.attributes.emoji`',
+ 'data.meta.emoji-icon' => 'Missing `data.meta.emoji-icon`',
+ 'data.relationships.posting.data.id' => 'Missing `data.relationships.posting.data.id`',
+ ];
+
+ foreach ($required_keys as $key => $error_message) {
+ if (!self::arrayHas($json, $key)) {
+ return $error_message;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingReactions.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactions.php
new file mode 100644
index 0000000..d5dde95
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingReactions.php
@@ -0,0 +1,43 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumPostingReactions extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumPostingReaction::REL_POSTING,
+ \JsonApi\Schemas\Forum\ForumPostingReaction::REL_USER
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $posting = \Forum\ForumPosting::find($args['posting_id']);
+
+ if (!$posting) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($posting->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $reactions = $posting->reactions ?? \SimpleORMapCollection::createFromArray([]);
+
+ return $this->getPaginatedContentResponse(
+ $reactions->limit(...$this->getOffsetAndLimit()),
+ count($reactions)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingShow.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingShow.php
new file mode 100644
index 0000000..ec64bb6
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingShow.php
@@ -0,0 +1,40 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+
+class ForumPostingShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumPosting::REL_DISCUSSION,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_POSTING,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_OPENGRAPH_URLS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS_USER
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $posting = \Forum\ForumPosting::find($args['posting_id']);
+
+ if (!$posting) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($posting->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ return $this->getContentResponse($posting);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingStore.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingStore.php
new file mode 100644
index 0000000..b6ecf6e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingStore.php
@@ -0,0 +1,100 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Forum\Enum\SubscriptionNotificationType;
+use Studip\Markup;
+use Forum\ForumDiscussion;
+use Forum\ForumPosting;
+use Forum\ForumPostingRead;
+use Forum\ForumSubscription;
+
+class ForumPostingStore extends JsonApiController
+{
+ use ValidationTrait;
+
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumPosting::REL_DISCUSSION,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_POSTING,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_OPENGRAPH_URLS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_AUTHOR,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS_USER
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $json = $this->validate($request);
+ $user = $this->getUser($request);
+
+ $discussion = ForumDiscussion::find(self::arrayGet($json, 'data.relationships.discussion.data.id'));
+ $course = \Course::find($discussion->range_id);
+
+ if (!$discussion || !$course) {
+ throw new RecordNotFoundException();
+ }
+
+ if (
+ !CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC) ||
+ $discussion->closed_at
+ ) {
+ throw new AuthorizationFailedException();
+ }
+
+ $parent_id = self::arrayGet($json, 'data.relationships.posting.data.id');
+
+ $psoting = ForumPosting::create([
+ 'range_id' => $discussion->range_id,
+ 'parent_id' => $parent_id ?? null,
+ 'discussion_id' => $discussion->discussion_id,
+ 'content' => Markup::markAsHtml(self::arrayGet($json, 'data.attributes.content')),
+ 'anonymous' => (self::arrayGet($json, 'data.attributes.anonymous') && \Config::get()->FORUM_ANONYMOUS_POSTINGS),
+ 'user_id' => $user->user_id
+ ]);
+
+ $subscription = ForumSubscription::findOneBySQL(
+ "user_id = :user_id AND subject_id IN (:subject_ids)",
+ [
+ 'user_id' => $user->user_id,
+ 'subject_ids' => [$discussion->discussion_id, $discussion->topic_id]
+ ]
+ );
+
+ if (!$subscription) {
+ $subscription = new ForumSubscription();
+ $subscription->user_id = $user->user_id;
+ $subscription->range_id = $discussion->range_id;
+ $subscription->subject_id = $discussion->discussion_id;
+ $subscription->subject = 'discussion';
+ $subscription->notification_type = SubscriptionNotificationType::All->value;
+ $subscription->store();
+ }
+
+ ForumPostingRead::updateUserReadPoint($user->user_id, $discussion->discussion_id);
+
+ return $this->getCreatedResponse($psoting);
+ }
+
+ protected function validateResourceDocument($json, $data)
+ {
+ $required_keys = [
+ 'data.attributes.content' => 'Missing `data.attributes.content`',
+ 'data.attributes.anonymous' => 'Missing `data.attributes.anonymous`',
+ 'data.relationships.discussion.data.id' => 'Missing `data.relationships.discussion.data.id`',
+ ];
+
+ foreach ($required_keys as $key => $error_message) {
+ if (!self::arrayHas($json, $key)) {
+ return $error_message;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumPostingUpdate.php b/lib/classes/JsonApi/Routes/Forum/ForumPostingUpdate.php
new file mode 100644
index 0000000..b8470fa
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumPostingUpdate.php
@@ -0,0 +1,69 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Studip\Markup;
+use Forum\ForumPosting;
+
+class ForumPostingUpdate extends JsonApiController
+{
+ use ValidationTrait;
+
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumPosting::REL_DISCUSSION,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_POSTING,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_OPENGRAPH_URLS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_AUTHOR,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS,
+ \JsonApi\Schemas\Forum\ForumPosting::REL_REACTIONS_USER
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $json = $this->validate($request);
+ $user = $this->getUser($request);
+
+ $posting = ForumPosting::findOneBySQL(
+ "posting_id = :posting_id AND user_id = :user_id",
+ [
+ 'posting_id' => $args['posting_id'],
+ 'user_id' => $user->user_id
+ ]
+ );
+
+ if (!$posting) {
+ throw new RecordNotFoundException();
+ }
+
+ if ($posting->discussion->closed_at) {
+ throw new AuthorizationFailedException();
+ }
+
+ $posting->content = Markup::markAsHtml(self::arrayGet($json, 'data.attributes.content'));
+ $posting->anonymous = (self::arrayGet($json, 'data.attributes.anonymous') && \Config::get()->FORUM_ANONYMOUS_POSTINGS);
+ $posting->store();
+
+ return $this->getCreatedResponse($posting);
+ }
+
+ protected function validateResourceDocument($json, $data)
+ {
+ $required_keys = [
+ 'data.attributes.content' => 'Missing `data.attributes.content`',
+ 'data.attributes.anonymous' => 'Missing `data.attributes.anonymous`',
+ ];
+
+ foreach ($required_keys as $key => $error_message) {
+ if (!self::arrayHas($json, $key)) {
+ return $error_message;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionDelete.php b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionDelete.php
new file mode 100644
index 0000000..fef6ac0
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionDelete.php
@@ -0,0 +1,32 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumSubscription;
+
+class ForumSubscriptionDelete extends JsonApiController
+{
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $user = $this->getUser($request);
+
+ $subscription = ForumSubscription::findOneBySQL(
+ "id = :subscription_id AND user_id = :user_id",
+ [
+ 'subscription_id' => $args['subscription_id'],
+ 'user_id' => $user->user_id
+ ]
+ );
+
+ if (!$subscription) {
+ throw new RecordNotFoundException();
+ }
+
+ $subscription->delete();
+
+ return $this->getCodeResponse(204);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionIndex.php
new file mode 100644
index 0000000..5771d19
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionIndex.php
@@ -0,0 +1,45 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumSubscription;
+
+class ForumSubscriptionIndex extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumSubscription::REL_RANGE,
+ \JsonApi\Schemas\Forum\ForumSubscription::REL_SUBJECT,
+ \JsonApi\Schemas\Forum\ForumSubscription::REL_USER
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ if (!$course = \Course::find($args['course_id'])) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $subscriptions = ForumSubscription::findBySQL(
+ "range_id = :course_id AND user_id = :user_id ORDER BY mkdate DESC",
+ [
+ 'course_id' => $course->id,
+ 'user_id' => $user->user_id
+ ]
+ );
+
+ return $this->getPaginatedContentResponse(
+ array_slice($subscriptions, ...$this->getOffsetAndLimit()),
+ count($subscriptions)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionShow.php b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionShow.php
new file mode 100644
index 0000000..dd0854d
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionShow.php
@@ -0,0 +1,36 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumSubscription;
+
+class ForumSubscriptionShow extends JsonApiController
+{
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumSubscription::REL_RANGE,
+ \JsonApi\Schemas\Forum\ForumSubscription::REL_SUBJECT,
+ \JsonApi\Schemas\Forum\ForumSubscription::REL_USER,
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $user = $this->getUser($request);
+
+ $subscription = ForumSubscription::findOneBySQL(
+ "id = :id AND user_id = :user_id",
+ [
+ 'id' => $args['subscription_id'],
+ 'user_id' => $user->user_id
+ ]
+ );
+
+ if (!$subscription) {
+ throw new RecordNotFoundException();
+ }
+
+ return $this->getContentResponse($subscription);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionStore.php b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionStore.php
new file mode 100644
index 0000000..ea3f308
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumSubscriptionStore.php
@@ -0,0 +1,86 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\BadRequestException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Forum\ForumDiscussion;
+use Forum\ForumSubscription;
+
+class ForumSubscriptionStore extends JsonApiController
+{
+ use ValidationTrait;
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $json = $this->validate($request);
+ $user = $this->getUser($request);
+ $subjectType = $this->mapSubjectType(self::arrayGet($json, 'data.relationships.subject.data.type'));
+
+ if ($subjectType === 'discussion') {
+ $discussion = ForumDiscussion::find(self::arrayGet($json, 'data.relationships.subject.data.id'));
+
+ if (!$discussion || $discussion->closed_at) {
+ throw new AuthorizationFailedException();
+ }
+ }
+
+ if (!self::arrayHas($json, 'data.id')) {
+ $subscription = new ForumSubscription();
+ $subscription->user_id = $user->user_id;
+ } else {
+ $subscription = ForumSubscription::findOneBySQL(
+ "id = :id AND user_id = :user_id",
+ [
+ 'id' => self::arrayGet($json, 'data.id'),
+ 'user_id' => $user->user_id
+ ]
+ );
+
+ if (!$subscription) {
+ throw new BadRequestException();
+ }
+ }
+
+ $subscription->range_id = self::arrayGet($json, 'data.relationships.range.data.id');
+ $subscription->subject_id = self::arrayGet($json, 'data.relationships.subject.data.id');
+ $subscription->subject = $subjectType;
+ $subscription->notification_type = self::arrayGet($json, 'data.attributes.notification-type');
+
+ $subscription->store();
+
+ return $this->getCreatedResponse($subscription);
+ }
+
+ protected function validateResourceDocument($json, $data)
+ {
+ $required_keys = [
+ 'data' => 'Missing `data`',
+ 'data.attributes' => 'Missing `data.attributes`',
+ 'data.attributes.notification-type' => 'Missing `data.attributes.notification-type`',
+ 'data.relationships' => 'Missing `data.relationships`',
+ 'data.relationships.range.data.id' => 'Missing `data.relationships.range.data.id`',
+ 'data.relationships.subject.data.id' => 'Missing `data.relationships.subject.data.id`',
+ 'data.relationships.subject.data.type' => 'Missing `data.relationships.subject.data.type`',
+ ];
+
+ foreach ($required_keys as $key => $error_message) {
+ if (!self::arrayHas($json, $key)) {
+ return $error_message;
+ }
+ }
+
+ return null;
+ }
+
+ private function mapSubjectType($type): string
+ {
+ return match ($type) {
+ 'forum-discussions' => 'discussion',
+ 'forum-topics' => 'topic'
+ };
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumTopicDiscussions.php b/lib/classes/JsonApi/Routes/Forum/ForumTopicDiscussions.php
new file mode 100644
index 0000000..68bd09a
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumTopicDiscussions.php
@@ -0,0 +1,50 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\BadRequestException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use Forum\ForumCategory;
+use Forum\ForumSubscription;
+use Forum\ForumTopic;
+
+class ForumTopicDiscussions extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumCategory::REL_TOPICS,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_CATEGORY,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_DISCUSSION_TYPE,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_MEMBERS,
+ \JsonApi\Schemas\Forum\ForumDiscussion::REL_TAGS
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $topic = \Forum\ForumTopic::find($args['topic_id']);
+
+ if (!$topic) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($topic->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $discussions = $topic->discussions ?? \SimpleORMapCollection::createFromArray([]);
+
+ return $this->getPaginatedContentResponse(
+ $discussions->limit(...$this->getOffsetAndLimit()),
+ count($discussions)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumTopicIndex.php b/lib/classes/JsonApi/Routes/Forum/ForumTopicIndex.php
new file mode 100644
index 0000000..0379bfa
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumTopicIndex.php
@@ -0,0 +1,38 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\JsonApiController;
+use Forum\ForumTopic;
+
+class ForumTopicIndex extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+ protected $allowedFilteringParameters = ['course-id'];
+ protected $allowedIncludePaths = [
+ \JsonApi\Schemas\Forum\ForumTopic::REL_CATEGORY
+ ];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ if (!$course = \Course::find($args['course_id'])) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $topics = ForumTopic::getCourseTopics($course->id);
+
+ return $this->getPaginatedContentResponse(
+ array_slice($topics, ...$this->getOffsetAndLimit()),
+ count($topics)
+ );
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumTopicShow.php b/lib/classes/JsonApi/Routes/Forum/ForumTopicShow.php
new file mode 100644
index 0000000..d4170df
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumTopicShow.php
@@ -0,0 +1,33 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Routes\Courses\Authority as CourseAuthority;
+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 Forum\ForumTopic;
+
+class ForumTopicShow extends JsonApiController
+{
+ protected $allowedPagingParameters = ['offset', 'limit'];
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ if (!$topic = ForumTopic::find($args['topic_id'])) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!$course = \Course::find($topic->range_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ $user = $this->getUser($request);
+ if (!CourseAuthority::canShowCourse($user, $course, CourseAuthority::SCOPE_BASIC)) {
+ throw new AuthorizationFailedException();
+ }
+
+ return $this->getContentResponse($topic);
+ }
+}
diff --git a/lib/classes/JsonApi/Routes/Forum/ForumTopicUpdateSort.php b/lib/classes/JsonApi/Routes/Forum/ForumTopicUpdateSort.php
new file mode 100644
index 0000000..4926487
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Forum/ForumTopicUpdateSort.php
@@ -0,0 +1,61 @@
+<?php
+namespace JsonApi\Routes\Forum;
+
+use JsonApi\Errors\RecordNotFoundException;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\JsonApiController;
+use JsonApi\Routes\ValidationTrait;
+use Forum\ForumTopic;
+
+class ForumTopicUpdateSort extends JsonApiController
+{
+ use ValidationTrait;
+
+ public function __invoke(Request $request, Response $response, $args)
+ {
+ $json = $this->validate($request);
+ $course_id = self::arrayGet($json, 'data.relationships.range.data.id');
+
+ if (!$course = \Course::find($course_id)) {
+ throw new RecordNotFoundException();
+ }
+
+ if (!\CoreForum::isModerator($course->id)) {
+ throw new AuthorizationFailedException();
+ }
+
+ $topic_ids = self::arrayGet($json, 'data.attributes.topic-ids');
+
+ ForumTopic::findEachBySQL(
+ function (ForumTopic $topic) use ($topic_ids) {
+ $topic->position = (int) array_search($topic->topic_id, $topic_ids);
+ $topic->store();
+ },
+ "topic_id IN (:topic_ids) AND range_id = :course_id",
+ [
+ "topic_ids" => $topic_ids,
+ "course_id" => $course->id
+ ]
+ );
+
+ return $this->getCodeResponse(204);
+ }
+
+ protected function validateResourceDocument($json, $data)
+ {
+ $required_keys = [
+ 'data.attributes.topic-ids' => 'Missing `data.attributes.topic-ids`',
+ 'data.relationships.range.data.id' => 'Missing `data.relationships.range.data.id`',
+ ];
+
+ foreach ($required_keys as $key => $error_message) {
+ if (!self::arrayHas($json, $key)) {
+ return $error_message;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index b3c3179..c5acad7 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -39,8 +39,15 @@ class SchemaMap
\Degree::class => Schemas\Degree::class,
\FeedbackElement::class => Schemas\FeedbackElement::class,
\FeedbackEntry::class => Schemas\FeedbackEntry::class,
- \JsonApi\Models\ForumCat::class => Schemas\ForumCategory::class,
- \JsonApi\Models\ForumEntry::class => Schemas\ForumEntry::class,
+ \Forum\ForumCategory::class => \JsonApi\Schemas\Forum\ForumCategory::class,
+ \Forum\ForumTopic::class => \JsonApi\Schemas\Forum\ForumTopic::class,
+ \Forum\ForumDiscussion::class => \JsonApi\Schemas\Forum\ForumDiscussion::class,
+ \Forum\ForumDiscussionType::class => \JsonApi\Schemas\Forum\ForumDiscussionType::class,
+ \Forum\ForumPosting::class => \JsonApi\Schemas\Forum\ForumPosting::class,
+ \Forum\ForumPostingReaction::class => \JsonApi\Schemas\Forum\ForumPostingReaction::class,
+ \Forum\ForumSubscription::class => \JsonApi\Schemas\Forum\ForumSubscription::class,
+ \Forum\DTO\ForumMember::class => \JsonApi\Schemas\Forum\ForumMember::class,
+ \Forum\DTO\ForumTag::class => \JsonApi\Schemas\Forum\ForumTag::class,
\Institute::class => Schemas\Institute::class,
\InstituteMember::class => Schemas\InstituteMember::class,
\LtiTool::class => Schemas\LtiTool::class,
diff --git a/lib/classes/JsonApi/Schemas/Activity.php b/lib/classes/JsonApi/Schemas/Activity.php
index fbdd6e7..3839793 100644
--- a/lib/classes/JsonApi/Schemas/Activity.php
+++ b/lib/classes/JsonApi/Schemas/Activity.php
@@ -89,7 +89,7 @@ class Activity extends SchemaProvider
{
$mapping = [
'documents' => \FileRef::class,
- 'forum' => \JsonApi\Models\ForumEntry::class,
+ 'forum' => \Forum\ForumPosting::class,
'message' => \Message::class,
'news' => \StudipNews::class,
'participants' => \Course::class,
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumCategory.php b/lib/classes/JsonApi/Schemas/Forum/ForumCategory.php
new file mode 100644
index 0000000..551201a
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumCategory.php
@@ -0,0 +1,74 @@
+<?php
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class ForumCategory extends SchemaProvider
+{
+ const TYPE = 'forum-categories';
+ const REL_TOPICS = 'topics';
+
+ public function getId($category): ?string
+ {
+ return $category->id;
+ }
+
+ public function getAttributes($category, ContextInterface $context): iterable
+ {
+ return [
+ 'name' => $category->name,
+ 'description' => $category->description,
+ 'color' => $category->color,
+ 'position' => (int) $category->position,
+ 'mkdate' => date('c', $category->mkdate),
+ 'chdate' => date('c', $category->chdate)
+ ];
+ }
+
+ public function hasResourceMeta($category): bool
+ {
+ return true;
+ }
+
+ public function getResourceMeta($category)
+ {
+ $metaData = $category->getMetaData();
+
+ return [
+ 'topics-count' => (int) $metaData['topics_count'],
+ 'discussions-count' => (int) $metaData['discussions_count'],
+ 'postings-count' => (int) $metaData['postings_count'],
+ 'recent-postings-count' => (int) $metaData['recent_postings_count'],
+ 'user-read-index' => (int) $metaData['user_read_index'],
+ 'users-count' => (int) $metaData['users_count'],
+ 'recent-activity' => $metaData['recent_activity'] ? date('c', $metaData['recent_activity']) : '',
+ ];
+ }
+
+ public function getRelationships($category, ContextInterface $context): iterable
+ {
+ $relationships = [];
+ $relationships = $this->addTopicsRelationship($relationships, $category, $this->shouldInclude($context, self::REL_TOPICS));
+
+ return $relationships;
+ }
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ private function addTopicsRelationship($relationships, $category, $withTopics = false)
+ {
+ if ($withTopics) {
+ $relationships[self::REL_TOPICS] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($category, self::REL_TOPICS)
+ ],
+ self::RELATIONSHIP_DATA => $category->topics
+ ];
+ }
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumDiscussion.php b/lib/classes/JsonApi/Schemas/Forum/ForumDiscussion.php
new file mode 100644
index 0000000..d095325
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumDiscussion.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class ForumDiscussion extends SchemaProvider
+{
+ const TYPE = 'forum-discussions';
+ const REL_POSTINGS = 'postings';
+ const REL_TOPIC = 'topic';
+ const REL_CATEGORY = 'category';
+ const REL_DISCUSSION_TYPE = 'discussion-type';
+ const REL_MEMBERS = 'members';
+ const REL_TAGS = 'tags';
+
+ public function getId($discussion): ?string
+ {
+ return $discussion->discussion_id;
+ }
+
+ public function getAttributes($discussion, ContextInterface $context): iterable
+ {
+ return [
+ 'title' => $discussion->title,
+ 'closed-at' => $discussion->closed_at ? date('c', $discussion->closed_at) : null,
+ 'sticky' => (bool) $discussion->sticky,
+ 'view-count' => (int) $discussion->view_count,
+ 'mkdate' => date('c', $discussion->mkdate),
+ 'chdate' => date('c', $discussion->chdate)
+ ];
+ }
+
+ public function hasResourceMeta($discussion): bool
+ {
+ return true;
+ }
+
+ public function getResourceMeta($discussion)
+ {
+ $metaData = $discussion->getMetaData();
+
+ return [
+ 'postings-count' => (int) $metaData['postings_count'],
+ 'recent-postings-count' => (int) $metaData['recent_postings_count'],
+ 'user-read-index' => (int) $metaData['user_read_index'],
+ 'recent-activity' => $metaData['recent_activity'] ? date('c', $metaData['recent_activity']) : ''
+ ];
+ }
+
+ public function getRelationships($discussion, ContextInterface $context): iterable
+ {
+ $relationships = [];
+
+ $relationships = $this->addPostingsRelationship($relationships, $discussion, $this->shouldInclude($context, self::REL_POSTINGS));
+ $relationships = $this->addTopicRelationship($relationships, $discussion, $this->shouldInclude($context, self::REL_TOPIC));
+ $relationships = $this->addCategoryRelationship($relationships, $discussion, $this->shouldInclude($context, self::REL_CATEGORY));
+ $relationships = $this->addDiscussionTypeRelationship($relationships, $discussion, $this->shouldInclude($context, self::REL_DISCUSSION_TYPE));
+ $relationships = $this->addMembersRelationship($relationships, $discussion, $this->shouldInclude($context, self::REL_MEMBERS));
+ $relationships = $this->addTagsRelationship($relationships, $discussion, $this->shouldInclude($context, self::REL_TAGS));
+
+ return $relationships;
+ }
+
+ private function addPostingsRelationship(array $relationships, $discussion, bool $withPostings = false)
+ {
+ if ($withPostings) {
+ $relationships[self::REL_POSTINGS] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($discussion, self::REL_POSTINGS)
+ ],
+ self::RELATIONSHIP_DATA => $discussion->postings
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addTopicRelationship(array $relationships, $discussion, bool $withTopic = false)
+ {
+ if ($withTopic) {
+ $relationships[self::REL_TOPIC] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($discussion->topic)
+ ],
+ self::RELATIONSHIP_DATA => $discussion->topic
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addCategoryRelationship(array $relationships, $discussion, bool $withCategory = false)
+ {
+ $category = $discussion->category;
+ if ($withCategory && $category) {
+
+ $relationships[self::REL_CATEGORY] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($category)
+ ],
+ self::RELATIONSHIP_DATA => $category
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addDiscussionTypeRelationship(array $relationships, $discussion, bool $withDiscussionType = false)
+ {
+ $discussionType = $discussion->discussion_type;
+
+ if ($withDiscussionType && $discussionType) {
+ $relationships[self::REL_DISCUSSION_TYPE] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($discussionType)
+ ],
+ self::RELATIONSHIP_DATA => $discussionType
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addMembersRelationship(array $relationships, $discussion, bool $withMembers = false)
+ {
+ if ($withMembers) {
+ $relationships[self::REL_MEMBERS] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($discussion, self::REL_MEMBERS)
+ ],
+ self::RELATIONSHIP_DATA => $discussion->members
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addTagsRelationship(array $relationships, $discussion, bool $withTags = false)
+ {
+ if ($withTags) {
+ $relationships[self::REL_TAGS] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($discussion, self::REL_TAGS)
+ ],
+ self::RELATIONSHIP_DATA => $discussion->tags
+ ];
+ }
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumDiscussionType.php b/lib/classes/JsonApi/Schemas/Forum/ForumDiscussionType.php
new file mode 100644
index 0000000..7bdf6bb
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumDiscussionType.php
@@ -0,0 +1,54 @@
+<?php
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class ForumDiscussionType extends SchemaProvider
+{
+ const TYPE = 'forum-discussion-types';
+
+ const REL_DISCUSSIONS = 'discussions';
+
+ public function getId($discussionType): ?string
+ {
+ return $discussionType->type_id;
+ }
+
+ public function getAttributes($discussionType, ContextInterface $context): iterable
+ {
+ return [
+ 'name' => $discussionType->name,
+ 'icon' => $discussionType->icon,
+ 'mkdate' => date('c', $discussionType->mkdate),
+ 'chdate' => date('c', $discussionType->chdate),
+ ];
+ }
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function getRelationships($discussionType, ContextInterface $context): iterable
+ {
+ $relationships = [];
+
+ $relationships = $this->addDiscussionsRelationship($relationships, $discussionType, $this->shouldInclude($context, self::REL_DISCUSSIONS));
+
+ return $relationships;
+ }
+
+ private function addDiscussionsRelationship($relationships, $discussionType, $withDiscussions = false)
+ {
+ if ($withDiscussions) {
+ $relationships[self::REL_DISCUSSIONS] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($discussionType, self::REL_DISCUSSIONS)
+ ],
+ self::RELATIONSHIP_DATA => $discussionType->discussions
+ ];
+ }
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumMember.php b/lib/classes/JsonApi/Schemas/Forum/ForumMember.php
new file mode 100644
index 0000000..71a0226
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumMember.php
@@ -0,0 +1,30 @@
+<?php
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+
+class ForumMember extends SchemaProvider
+{
+ const TYPE = 'forum-members';
+
+ public function getId($member): ?string
+ {
+ return $member->id;
+ }
+
+ public function getAttributes($member, ContextInterface $context): iterable
+ {
+ return [
+ 'username' => $member->username,
+ 'name' => $member->name,
+ 'role' => $member->role,
+ 'avatar_url' => $member->avatar_url
+ ];
+ }
+
+ public function getRelationships($resource, ContextInterface $context): iterable
+ {
+ return [];
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumPosting.php b/lib/classes/JsonApi/Schemas/Forum/ForumPosting.php
new file mode 100644
index 0000000..5506f55
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumPosting.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class ForumPosting extends SchemaProvider
+{
+ const TYPE = 'forum-postings';
+ const REL_AUTHOR = 'author';
+ const REL_DISCUSSION = 'discussion';
+ const REL_POSTING = 'posting';
+ const REL_RANGE = 'range';
+ const REL_REACTIONS = 'reactions';
+ const REL_REACTIONS_USER = 'reactions.user';
+ const REL_OPENGRAPH_URLS = 'opengraph-urls';
+
+ public function getId($posting): ?string
+ {
+ return $posting->posting_id;
+ }
+
+ public function getAttributes($posting, ContextInterface $context): iterable
+ {
+ return [
+ 'content' => formatReady($posting->content),
+ 'anonymous' => (bool) $posting->anonymous,
+ 'mkdate' => date('c', $posting->mkdate),
+ 'chdate' => date('c', $posting->chdate)
+ ];
+ }
+
+ public function hasResourceMeta($posting): bool
+ {
+ return true;
+ }
+
+ public function getResourceMeta($posting)
+ {
+ return [
+ self::REL_OPENGRAPH_URLS => array_map(fn($og) => [
+ 'url' => $og['url'],
+ 'is-opengraph' => (bool) $og['is_opengraph'],
+ 'title' => $og['title'],
+ 'description' => $og['description'],
+ 'image' => $og['image'],
+ ], $posting->getOpenGraphURLs()->toArray())
+ ];
+ }
+
+ public function getRelationships($posting, ContextInterface $context): iterable
+ {
+ $relationships = [];
+ $relationships = $this->addAuthorRelationship($relationships, $posting, $this->shouldInclude($context, self::REL_AUTHOR));
+ $relationships = $this->addDiscussionRelationship($relationships, $posting, $this->shouldInclude($context, self::REL_DISCUSSION));
+ $relationships = $this->addPostingRelationship($relationships, $posting, $this->shouldInclude($context, self::REL_POSTING));
+ $relationships = $this->addReactionsRelationship($relationships, $posting, $this->shouldInclude($context, self::REL_REACTIONS));
+
+ return $relationships;
+ }
+
+ private function addAuthorRelationship($relationships, $posting, $withAuthor = false)
+ {
+ $author = $posting->author;
+
+ if ($withAuthor && $author) {
+ $relationships[self::REL_AUTHOR] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($author)
+ ],
+ self::RELATIONSHIP_DATA => $author
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addDiscussionRelationship($relationships, $posting, $withDiscussion = false)
+ {
+ if ($withDiscussion) {
+ $relationships[self::REL_DISCUSSION] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($posting->discussion)
+ ],
+ self::RELATIONSHIP_DATA => $posting->discussion
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addPostingRelationship($relationships, $posting, $withPosting = false)
+ {
+ $posting = $posting->posting;
+
+ if ($withPosting && $posting) {
+ $relationships[self::REL_POSTING] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($posting)
+ ],
+ self::RELATIONSHIP_DATA => $posting
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addReactionsRelationship($relationships, $posting, $withReactions = false)
+ {
+ if ($withReactions) {
+ $relationships[self::REL_REACTIONS] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($posting, self::REL_REACTIONS)
+ ],
+ self::RELATIONSHIP_DATA => $posting->reactions
+ ];
+ }
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumPostingReaction.php b/lib/classes/JsonApi/Schemas/Forum/ForumPostingReaction.php
new file mode 100644
index 0000000..1b7bfa9
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumPostingReaction.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class ForumPostingReaction extends SchemaProvider
+{
+ const TYPE = 'forum-posting-reactions';
+ const REL_POSTING = 'posting';
+ const REL_USER = 'user';
+
+ public function getId($postingReaction): ?string
+ {
+ return $postingReaction->id;
+ }
+
+ public function getAttributes($postingReaction, ContextInterface $context): iterable
+ {
+ return [
+ 'emoji' => $postingReaction->emoji,
+ 'mkdate' => date('c', $postingReaction->mkdate),
+ 'chdate' => date('c', $postingReaction->chdate)
+ ];
+ }
+
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function getRelationships($postingReaction, ContextInterface $context): iterable
+ {
+ $relationships = [];
+
+ $relationships = $this->addPostingRelationship($relationships, $postingReaction, $this->shouldInclude($context, self::REL_POSTING));
+ $relationships = $this->addUserRelationship($relationships, $postingReaction, $this->shouldInclude($context, self::REL_USER));
+
+ return $relationships;
+ }
+
+
+ private function addPostingRelationship(array $relationships, $postingReaction, bool $withPosting = false)
+ {
+ if ($withPosting) {
+ $relationships[self::REL_POSTING] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($postingReaction->posting)
+ ],
+ self::RELATIONSHIP_DATA => $postingReaction->posting
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addUserRelationship(array $relationships, $discussion, bool $withUser = false)
+ {
+ if ($withUser) {
+ $relationships[self::REL_USER] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($discussion->user)
+ ],
+ self::RELATIONSHIP_DATA => $discussion->user
+ ];
+ }
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumSubscription.php b/lib/classes/JsonApi/Schemas/Forum/ForumSubscription.php
new file mode 100644
index 0000000..ecfb699
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumSubscription.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class ForumSubscription extends SchemaProvider
+{
+ const TYPE = 'forum-subscriptions';
+ const REL_USER = 'user';
+ const REL_RANGE = 'range';
+ const REL_SUBJECT = 'subject';
+
+ public function getId($subscription): ?string
+ {
+ return $subscription->id;
+ }
+
+ public function getAttributes($subscription, ContextInterface $context): iterable
+ {
+ return [
+ 'notification-type' => $subscription->notification_type,
+ 'mkdate' => date('c', $subscription->mkdate),
+ 'chdate' => date('c', $subscription->chdate)
+ ];
+ }
+
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ */
+ public function getRelationships($subscription, ContextInterface $context): iterable
+ {
+ $isPrimary = $context->getPosition()->getLevel() === 0;
+ $includeList = $context->getIncludePaths();
+
+ $relationships = [];
+ if ($isPrimary) {
+ $relationships = $this->addUserRelationship($relationships, $subscription, $includeList);
+ $relationships = $this->addRangeRelationship($relationships, $subscription, $includeList);
+ $relationships = $this->addSubjectRelationship($relationships, $subscription, $includeList);
+ }
+
+ return $relationships;
+ }
+
+ private function addUserRelationship(array $relationships, $subscription, array $includeList)
+ {
+ $relationships[self::REL_USER] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($subscription->user)
+ ],
+ self::RELATIONSHIP_DATA => $subscription->user
+ ];
+
+ return $relationships;
+ }
+
+ private function addSubjectRelationship(array $relationships, $subscription, array $includeList)
+ {
+ $relationships[self::REL_SUBJECT] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($subscription->subject_object)
+ ],
+ self::RELATIONSHIP_DATA => $subscription->subject_object
+ ];
+
+ return $relationships;
+ }
+
+ private function addRangeRelationship(array $relationships, $subscription, $includeList)
+ {
+ $relationships[self::REL_RANGE] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->createLinkToResource($subscription->range),
+ ],
+ self::RELATIONSHIP_DATA => $subscription->range,
+ ];
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumTag.php b/lib/classes/JsonApi/Schemas/Forum/ForumTag.php
new file mode 100644
index 0000000..00bf2e9
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumTag.php
@@ -0,0 +1,27 @@
+<?php
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+
+class ForumTag extends SchemaProvider
+{
+ const TYPE = 'forum-tags';
+
+ public function getId($tag): ?string
+ {
+ return $tag->id;
+ }
+
+ public function getAttributes($tag, ContextInterface $context): iterable
+ {
+ return [
+ 'name' => $tag->name
+ ];
+ }
+
+ public function getRelationships($resource, ContextInterface $context): iterable
+ {
+ return [];
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/Forum/ForumTopic.php b/lib/classes/JsonApi/Schemas/Forum/ForumTopic.php
new file mode 100644
index 0000000..1d310c2
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Forum/ForumTopic.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace JsonApi\Schemas\Forum;
+
+use JsonApi\Schemas\SchemaProvider;
+use JsonApi\Schemas\Studip;
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class ForumTopic extends SchemaProvider
+{
+ const TYPE = 'forum-topics';
+ const REL_CATEGORY = 'category';
+ const REL_DISCUSSION = 'discussion';
+
+ public function getId($topic): ?string
+ {
+ return $topic->topic_id;
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @param \Forum\ForumTopic $topic
+ */
+ public function getAttributes($topic, ContextInterface $context): iterable
+ {
+ return [
+ 'name' => $topic->name,
+ 'description' => $topic->description,
+ 'position' => (int) $topic->position,
+ 'mkdate' => date('c', $topic->mkdate),
+ 'chdate' => date('c', $topic->chdate)
+ ];
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @param \Forum\ForumTopic $topic
+ */
+ public function hasResourceMeta($topic): bool
+ {
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ *
+ * @param \Forum\ForumTopic $topic
+ */
+ public function getResourceMeta($topic)
+ {
+ $metaData = $topic->getMetaData();
+
+ return [
+ 'discussions-count' => (int) $metaData['discussions_count'],
+ 'postings-count' => (int) $metaData['postings_count'],
+ 'recent-postings-count' => (int) $metaData['recent_postings_count'],
+ 'user-read-index' => (int) $metaData['user_read_index'],
+ 'users-count' => (int) $metaData['users_count'],
+ 'recent-activity' => $metaData['recent_activity'] ? date('c', $metaData['recent_activity']) : '',
+ ];
+ }
+
+ /**
+ * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+ *
+ * @param \Forum\ForumTopic $topic
+ */
+ public function getRelationships($topic, ContextInterface $context): iterable
+ {
+ $relationships = [];
+ $relationships = $this->addCategoryRelationship($relationships, $topic, $this->shouldInclude($context, self::REL_CATEGORY));
+ $relationships = $this->addDiscussionsRelationship($relationships, $topic, $this->shouldInclude($context, self::REL_DISCUSSION));
+
+ return $relationships;
+ }
+
+ private function addCategoryRelationship($relationships, $topic, $withCategory = false)
+ {
+ if ($withCategory) {
+ $relationships[self::REL_CATEGORY] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($topic, self::REL_CATEGORY)
+ ],
+ self::RELATIONSHIP_DATA => $topic->category
+ ];
+ }
+
+ return $relationships;
+ }
+
+ private function addDiscussionsRelationship($relationships, $topic, $withDiscussions = false)
+ {
+ if ($withDiscussions) {
+ $relationships[self::REL_DISCUSSION] = [
+ self::RELATIONSHIP_LINKS => [
+ Link::RELATED => $this->getRelationshipRelatedLink($topic, self::REL_DISCUSSION)
+ ],
+ self::RELATIONSHIP_DATA => $topic->dicussions
+ ];
+ }
+
+ return $relationships;
+ }
+}
diff --git a/lib/classes/JsonApi/Schemas/ForumCategory.php b/lib/classes/JsonApi/Schemas/ForumCategory.php
deleted file mode 100644
index 4111464..0000000
--- a/lib/classes/JsonApi/Schemas/ForumCategory.php
+++ /dev/null
@@ -1,76 +0,0 @@
-<?php
-
-namespace JsonApi\Schemas;
-
-use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
-use Neomerx\JsonApi\Schema\Link;
-use JsonApi\Models\ForumEntry as Entry;
-
-class ForumCategory extends SchemaProvider
-{
- const TYPE = 'forum-categories';
- const REL_COURSE = 'course';
- const REL_ENTRY = 'entries';
-
-
-
- public function getId($category): ?string
- {
- return $category->id;
- }
-
- public function getAttributes($category, ContextInterface $context): iterable
- {
- return [
- 'title' => $category->entry_name,
- 'position' => (int) $category->pos,
- ];
- }
-
- public function getRelationships($category, ContextInterface $context): iterable
- {
- $isPrimary = $context->getPosition()->getLevel() === 0;
- $includeList = $context->getIncludePaths();
-
- $relationships = [];
- if ($isPrimary) {
- $relationships = $this->addCourseRelationship($category, $isPrimary, $includeList);
- $relationships = $this->addEntryRelationship($category, $isPrimary, $includeList);
- }
-
- return $relationships;
- }
-
- public function addCourseRelationship($category, $isPrimary, $includeList)
- {
- $data = $isPrimary && in_array(self::REL_COURSE, $includeList)
- ? \Course::find($category->seminar_id)
- : \Course::buildExisting(['id' => $category->seminar_id]);
- $link = $this->createLinkToResource($data);
- $relationships = [
- self::REL_COURSE => [
- self::RELATIONSHIP_LINKS => [Link::RELATED => $link],
- self::RELATIONSHIP_DATA => $data,
- ],
- ];
-
- return $relationships;
- }
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- private function addEntryRelationship($category, $isPrimary, $includeList)
- {
- $data = Entry::getEntriesFromCat($category);
- $link = $this->getRelationshipRelatedLink($category, self::REL_ENTRY);
- $relationships[self::REL_ENTRY] = [
- self::RELATIONSHIP_DATA => $data,
- self::RELATIONSHIP_LINKS => [
- Link::RELATED => $link,
- ],
- ];
-
- return $relationships;
- }
-}
diff --git a/lib/classes/JsonApi/Schemas/ForumEntry.php b/lib/classes/JsonApi/Schemas/ForumEntry.php
deleted file mode 100644
index e99be46..0000000
--- a/lib/classes/JsonApi/Schemas/ForumEntry.php
+++ /dev/null
@@ -1,78 +0,0 @@
-<?php
-
-namespace JsonApi\Schemas;
-
-use Neomerx\JsonApi\Schema\Link;
-use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
-use JsonApi\Models\ForumCat;
-
-class ForumEntry extends SchemaProvider
-{
- const TYPE = 'forum-entries';
- const REL_CAT = 'category';
- const REL_ENTRY = 'entries';
-
- public function getId($entry): ?string
- {
- return $entry->topic_id;
- }
-
- public function getAttributes($entry, ContextInterface $context): iterable
- {
- return [
- 'title' => $entry->name,
- 'area' => (int) $entry->area,
- 'content' => $entry->content,
- ];
- }
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- public function getRelationships($entry, ContextInterface $context): iterable
- {
- $isPrimary = $context->getPosition()->getLevel() === 0;
- $includeList = $context->getIncludePaths();
-
- $relationships = [];
- if ($isPrimary) {
- $relationships = $this->addCategoryRelationship($relationships, $entry, $includeList);
- $relationships = $this->addChildEntryRelationship($relationships, $entry, $includeList);
- }
-
- return $relationships;
- }
-
- private function addCategoryRelationship($relationships, $entry, $includeList)
- {
- $cat_link = $this->createLinkToResource($entry->category);
- $cat_data = in_array(self::REL_CAT, $includeList)
- ? ForumCat::find($entry->category->id)
- : ForumCat::buildExisting(['id' => $entry->category->id]);
-
- $relationships[self::REL_CAT] = [
- self::RELATIONSHIP_LINKS => [
- Link::RELATED => $cat_link,
- ],
- self::RELATIONSHIP_DATA => $cat_data,
- ];
-
- return $relationships;
- }
-
- /**
- * @SuppressWarnings(PHPMD.UnusedFormalParameter)
- */
- private function addChildEntryRelationship($relationships, $entry, $includeList)
- {
- $relationships[self::REL_ENTRY] = [
- self::RELATIONSHIP_DATA => $entry->getChildEntries($entry->id),
-
- self::RELATIONSHIP_LINKS => [
- Link::RELATED => $this->getRelationshipRelatedLink($entry, self::REL_ENTRY),
- ],
- ];
-
- return $relationships;
- }
-}
diff --git a/lib/classes/Privacy.php b/lib/classes/Privacy.php
index b38838c..d28ecf4 100644
--- a/lib/classes/Privacy.php
+++ b/lib/classes/Privacy.php
@@ -42,7 +42,7 @@ class Privacy
],
'content' => [
FileRef::class,
- ForumEntry::class,
+ \Forum\ForumPosting::class,
WikiPage::class,
Courseware\Unit::class,
Courseware\StructuralElement::class,
diff --git a/lib/classes/Score.php b/lib/classes/Score.php
index f6370ee..35c62c0 100644
--- a/lib/classes/Score.php
+++ b/lib/classes/Score.php
@@ -204,7 +204,7 @@ class Score
$forum = PluginEngine::getPlugin(CoreForum::class);
if ($forum && $forum->isEnabled()) {
- $tables[] = ['table' => 'forum_entries'];
+ $tables[] = ['table' => 'forum_postings'];
}
$blubber = PluginEngine::getPlugin(Blubber::class);
diff --git a/lib/classes/Siteinfo.php b/lib/classes/Siteinfo.php
index 4a969ee..8ca1c08 100644
--- a/lib/classes/Siteinfo.php
+++ b/lib/classes/Siteinfo.php
@@ -473,37 +473,6 @@ class SiteinfoMarkupEngine {
LIMIT 10";
$template->type = "seminar";
break;
- case "mostpostings":
- $template->heading = _("die aktivsten Veranstaltungen (Postings der letzten zwei Wochen)");
- $seminars = [];
-
- // get TopTen of seminars from all ForumModules and add up the
- // count for seminars with more than one active ForumModule
- // to get a combined toplist
- foreach (PluginEngine::getPlugins(ForumModule::class) as $plugin) {
- $new_seminars = $plugin->getTopTenSeminars();
- foreach ($new_seminars as $sem) {
- if (!isset($seminars[$sem['seminar_id']])) {
- $seminars[$sem['seminar_id']] = $sem;
- } else {
- $seminars[$sem['seminar_id']]['count'] += $sem['count'];
- }
- }
- }
-
- // sort the seminars by the number of combined postings
- usort($seminars, function($a, $b) {
- if ($a['count'] === $b['count']) {
- return 0;
- }
- return ($a['count'] > $b['count']) ? -1 : 1;
- });
-
- // fill the template and returned the rendered code
- $template->lines = $seminars;
- $template->type = "seminar";
-
- break;
case "mostvisitedhomepages":
$template->heading = _("die beliebtesten Profile (Besucher)");
$sql = "SELECT auth_user_md5.user_id,
@@ -586,16 +555,9 @@ class SiteinfoMarkupEngine {
"constraint" => Config::get()->RESOURCES_ENABLE];
if ($key === 'posting') {
- $count = 0;
-
- // sum up number of postings for all availabe ForumModules
- foreach (PluginEngine::getPlugins(ForumModule::class) as $plugin) {
- $count += $plugin->getNumberOfPostings();
- }
-
$template->title = _('Forenbeiträge');
$template->detail = _('Anzahl Beiträge aller verwendeten Foren');
- $template->count = $count;
+ $template->count = \Forum\ForumPosting::countBySql();
} else {
// iterate over the other indicators
if (in_array($key,array_keys($indicator))) {
diff --git a/lib/classes/StudipKing.php b/lib/classes/StudipKing.php
index ae5a14e..c77908b 100644
--- a/lib/classes/StudipKing.php
+++ b/lib/classes/StudipKing.php
@@ -115,23 +115,7 @@ class StudipKing {
private static function forum_kings()
{
- $kings = [];
-
- // sum up postings for all users from all ForumModules available
- foreach (PluginEngine::getPlugins(ForumModule::class) as $plugin) {
- $table = $plugin->getEntryTableInfo();
- $query = "SELECT user_id AS id, COUNT(*) AS num FROM ". $table['table'] ." GROUP BY user_id";
- $new_kings = self::select_kings($query);
- foreach ($new_kings as $user_id => $num) {
- if (!isset($kings[$user_id])) {
- $kings[$user_id] = $num;
- } else {
- $kings[$user_id] += $num;
- }
- }
- }
-
- return $kings;
+ return self::select_kings("SELECT user_id AS id, COUNT(*) AS num FROM (SELECT user_id FROM forum_postings) as `postings` GROUP BY user_id");
}
private static function files_kings()
diff --git a/lib/classes/UserManagement.php b/lib/classes/UserManagement.php
index 961a872..255bba1 100644
--- a/lib/classes/UserManagement.php
+++ b/lib/classes/UserManagement.php
@@ -1221,8 +1221,9 @@ class UserManagement
"DELETE FROM priorities WHERE user_id = ?",
"DELETE FROM help_tour_user WHERE user_id = ?",
"DELETE FROM personal_notifications_user WHERE user_id = ?",
- "DELETE FROM forum_abo_users WHERE user_id = ?",
- "DELETE FROM forum_favorites WHERE user_id = ?",
+ "DELETE FROM forum_subscriptions WHERE user_id = ?",
+ "DELETE FROM forum_posting_reads WHERE user_id = ?",
+ "DELETE FROM forum_posting_reactions WHERE user_id = ?",
"DELETE FROM comments WHERE user_id = ?",
"DELETE questionnaires FROM questionnaires LEFT JOIN questionnaire_assignments qa USING (`questionnaire_id`) WHERE qa.range_id = ?",
@@ -1235,7 +1236,7 @@ class UserManagement
"DELETE etask_tests, ea FROM etask_tests LEFT JOIN etask_assignments ea ON (`test_id` = ea.test_id) WHERE ea.range_type = 'user' AND user_id = ?",
"DELETE FROM etask_assignments WHERE range_type = 'user' AND range_id = ?",
- "UPDATE forum_entries SET author = '' WHERE user_id = ?",
+ "UPDATE forum_postings SET anonymous = 1 WHERE user_id = ?",
"UPDATE auth_user_md5 SET visible = 'never' WHERE user_id = ?",
"REPLACE INTO `user_info` (`user_id`, `hobby`, `lebenslauf`, `publi`, `schwerp`, `Home`, `privatnr`, `privatcell`, `privadr`, `score`, `geschlecht`, `mkdate`, `chdate`, `title_front`, `title_rear`, `preferred_language`, `smsforward_copy`, `smsforward_rec`, `email_forward`, `motto`, `lock_rule`) VALUES(?, '', '', '', '', '', '', '', '', 0, 0, 0, 0, '', '', NULL, 1, '', 0, '', '');"
diff --git a/lib/classes/forms/ColorInput.php b/lib/classes/forms/ColorInput.php
new file mode 100644
index 0000000..43dac93
--- /dev/null
+++ b/lib/classes/forms/ColorInput.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Studip\Forms;
+
+class ColorInput extends Input
+{
+ public function render()
+ {
+ $template = $GLOBALS['template_factory']->open('forms/color_input');
+ $template->title = $this->title;
+ $template->name = $this->name;
+ $template->value = $this->value;
+ $template->id = md5(uniqid());
+ $template->required = $this->required;
+ $template->attributes = arrayToHtmlAttributes($this->attributes);
+ return $template->render();
+ }
+}
diff --git a/lib/classes/globalsearch/GlobalSearchForum.php b/lib/classes/globalsearch/GlobalSearchForum.php
index 915fe23..845fcaa 100644
--- a/lib/classes/globalsearch/GlobalSearchForum.php
+++ b/lib/classes/globalsearch/GlobalSearchForum.php
@@ -49,7 +49,7 @@ class GlobalSearchForum extends GlobalSearchModule implements GlobalSearchFullte
if (!$GLOBALS['perm']->have_perm('admin')) {
$seminaruser = " AND EXISTS (
SELECT 1 FROM `seminar_user`
- WHERE `forum_entries`.`seminar_id` = `seminar_user`.`seminar_id`
+ WHERE `forum_postings`.`range_id` = `seminar_user`.`seminar_id`
AND `seminar_user`.`user_id` = " . DBManager::get()->quote($GLOBALS['user']->id) . "
) ";
}
@@ -81,11 +81,10 @@ class GlobalSearchForum extends GlobalSearchModule implements GlobalSearchFullte
$anonymous = "";
}
- $sql = "SELECT SQL_CALC_FOUND_ROWS `forum_entries`.*
- FROM `forum_entries`
+ $sql = "SELECT SQL_CALC_FOUND_ROWS `forum_postings`.*
+ FROM `forum_postings`
WHERE {$anonymous} (
- `name` LIKE {$query}
- OR `content` LIKE {$query}
+ `content` LIKE {$query}
)
{$semester_condition}
{$seminaruser}
diff --git a/lib/cronjobs/garbage_collector.php b/lib/cronjobs/garbage_collector.php
index 961e086..4a029ef 100644
--- a/lib/cronjobs/garbage_collector.php
+++ b/lib/cronjobs/garbage_collector.php
@@ -154,13 +154,6 @@ class GarbageCollectorJob extends CronJob
$statement->execute();
}
- // Remove outdated entries from forum_visits
- $query = "DELETE FROM `forum_visits`
- WHERE GREATEST(`visitdate`, `last_visitdate`) < UNIX_TIMESTAMP() - :threshold";
- DBManager::get()->execute($query, [
- ':threshold' => ForumVisit::LAST_VISIT_MAX,
- ]);
-
// clean db cache
$cache = new Studip\Cache\DbCache();
$cache->purge();
diff --git a/lib/models/Course.php b/lib/models/Course.php
index 0357b66..7b285a8 100644
--- a/lib/models/Course.php
+++ b/lib/models/Course.php
@@ -378,10 +378,8 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe
['course' => $course->id]
);
- //Delete forum entries:
- foreach (PluginEngine::getPlugins(ForumModule::class) as $forum_tool) {
- $forum_tool->deleteContents($course->id);
- }
+ //Delete forum contents:
+ CoreForum::deleteCourseContents($course->id);
//Delete all files:
$folder = Folder::findTopFolder($course->id);
diff --git a/lib/models/CourseTopic.php b/lib/models/CourseTopic.php
index 72d2167..fb31efc 100644
--- a/lib/models/CourseTopic.php
+++ b/lib/models/CourseTopic.php
@@ -50,13 +50,17 @@ class CourseTopic extends SimpleORMap
'class_name' => User::class,
'foreign_key' => 'author_id'
];
-
- $config['additional_fields']['forum_thread_url']['get'] = 'getForumThreadURL';
+ $config['has_and_belongs_to_many']['forum_topics'] = [
+ 'class_name' => \Forum\ForumTopic::class,
+ 'thru_table' => 'forum_topics_issues',
+ 'on_delete' => 'delete',
+ 'on_store' => 'store'
+ ];
$config['registered_callbacks']['before_create'][] = 'cbDefaultValues';
$config['registered_callbacks']['after_store'][] = 'cbUpdateConnectedContentModules';
- $config['registered_callbacks']['before_delete'][] = 'cbUnlinkConnectedContentModules';
+ $config['additional_fields']['forum_thread_url']['get'] = 'getForumThreadURL';
$config['i18n_fields']['title'] = true;
$config['i18n_fields']['description'] = true;
@@ -113,37 +117,27 @@ class CourseTopic extends SimpleORMap
}
/**
- * set or update connection with forum thread
- */
+ * set or update connection with forum thread
+ */
public function connectWithForumThread()
{
- if ($this->seminar_id) {
- $course = Course::find($this->seminar_id);
- try {
- $forum_module = $course->getTool(CoreForum::class);
- if ($forum_module instanceof ForumModule) {
- $forum_module->setThreadForIssue($this->id, $this->title, $this->description);
- return true;
- }
- } catch (\Studip\Exception $e) {
- return false;
- }
+ if ($this->seminar_id && !$this->forum_thread_url) {
+ $forum_topic = new \Forum\ForumTopic();
+ $forum_topic['range_id'] = $this->seminar_id;
+ $forum_topic['name'] = $this['title'];
+ $forum_topic['description'] = $this['description'];
+ $forum_topic->store();
+
+ $this->forum_topics[] = $forum_topic;
+ $this->store();
}
return false;
}
public function getForumThreadURL()
{
- if ($this->seminar_id) {
- $course = Course::find($this->seminar_id);
- try {
- $forum_module = $course->getTool(CoreForum::class);
- if ($forum_module instanceof ForumModule) {
- return html_entity_decode($forum_module->getLinkToThread($this->id));
- }
- } catch (\Studip\Exception $e) {
- return '';
- }
+ if (count($this->forum_topics) > 0) {
+ return URLHelper::getURL('dispatch.php/course/forum/topics/show/'. $this->forum_topics[0]['topic_id'], ['cid' => $this->forum_topics[0]['range_id']]);
}
return '';
}
@@ -152,25 +146,15 @@ class CourseTopic extends SimpleORMap
{
if ($this->isFieldDirty('title') || $this->isFieldDirty('description')) {
if ($this->forum_thread_url) {
- $this->connectWithForumThread();
+ foreach ($this->forum_topics as $forum_topic) {
+ $forum_topic['name'] = $this['title'];
+ $forum_topic['description'] = $this['description'];
+ $forum_topic->store();
+ }
}
}
}
- /**
- * Removes link information for forum topic and remove forum topic as well
- * if it is empty.
- */
- protected function cbUnlinkConnectedContentModules()
- {
- $query = "DELETE fei, fe
- FROM `forum_entries_issues` AS fei
- LEFT JOIN `forum_entries` AS fe
- ON fei.`topic_id` = fe.`topic_id` AND fe.`rgt` = fe.`lft` + 1
- WHERE `issue_id` = ?";
- DBManager::get()->execute($query, [$this->id]);
- }
-
protected function cbDefaultValues()
{
if (empty($this->content['priority'])) {
diff --git a/lib/models/Forum/ForumCategory.php b/lib/models/Forum/ForumCategory.php
new file mode 100644
index 0000000..341011f
--- /dev/null
+++ b/lib/models/Forum/ForumCategory.php
@@ -0,0 +1,107 @@
+<?php
+namespace Forum;
+
+use DBManager;
+
+/**
+ * @property string $category_id
+ * @property string $range_id
+ * @property string $name
+ * @property string $description
+ * @property string $color
+ * @property int $position
+ * @property int $mkdate
+ * @property int $chdate
+ *
+ * @property ForumTopic[] $topics
+ * @property array $metadata
+ */
+class ForumCategory extends \SimpleORMap
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_categories';
+ $config['has_many']['topics'] = [
+ 'class_name' => ForumTopic::class,
+ 'foreign_key' => 'category_id',
+ 'assoc_foreign_key' => 'category_id',
+ 'order_by' => 'ORDER BY position ASC, mkdate DESC',
+ ];
+
+ $config['additional_fields']['metadata']['get'] = 'getMetaData';
+
+ $config['registered_callbacks']['after_delete'][] = 'onDelete';
+
+ parent::configure($config);
+ }
+
+ public function getMetaData(): array
+ {
+ $user_id = \User::findCurrent()->user_id;
+ $object_user_visit = \ObjectUserVisit::findOneBySQL(
+ "object_id = :object_id AND plugin_id = :plugin_id AND user_id = :user_id",
+ [
+ 'object_id' => $this->range_id,
+ 'plugin_id' => \PluginEngine::getPlugin(\CoreForum::class)->getPluginId(),
+ 'user_id' => $user_id,
+ ]
+ );
+
+ return DBManager::get()->fetchOne(
+ "SELECT
+ COUNT(DISTINCT`forum_topics`.`topic_id`) AS 'topics_count',
+ COUNT(DISTINCT `forum_discussions`.`discussion_id`) AS 'discussions_count',
+ COUNT(DISTINCT `forum_postings`.`posting_id`) AS 'postings_count',
+ COUNT(DISTINCT `forum_postings`.`user_id`) AS 'users_count',
+ MAX(`forum_postings`.`mkdate`) AS 'recent_activity',
+ (
+ SELECT SUM(fpr.read_index)
+ FROM forum_topics ft2
+ LEFT JOIN forum_discussions fd2 USING (`topic_id`)
+ JOIN forum_posting_reads fpr
+ ON fpr.discussion_id = fd2.discussion_id
+ AND fpr.user_id = :user_id
+ WHERE ft2.category_id = :category_id
+ ) AS 'user_read_index',
+ (
+ SELECT
+ COUNT(DISTINCT fp.posting_id)
+ FROM forum_topics ft
+ JOIN forum_discussions fd USING(topic_id)
+ JOIN forum_postings fp ON fp.discussion_id = fd.discussion_id AND fp.mkdate > :last_visit
+ WHERE ft.category_id = :category_id
+ ) AS 'recent_postings_count'
+ FROM `forum_topics`
+ LEFT JOIN `forum_discussions` USING (`topic_id`)
+ LEFT JOIN `forum_postings` USING (`discussion_id`)
+ WHERE `forum_topics`.`category_id` = :category_id",
+ [
+ 'category_id' => $this->category_id,
+ 'user_id' => $user_id,
+ 'last_visit' => $object_user_visit->last_visitdate ?? 0
+ ]
+ );
+ }
+
+ public function transformData(): array
+ {
+ return [
+ 'category_id' => $this->category_id,
+ 'range_id' => $this->range_id,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'color' => $this->color,
+ 'position' => $this->position,
+ 'chdate' => date('c', $this->chdate),
+ 'mkdate' => date('c', $this->mkdate)
+ ];
+ }
+
+ public function onDelete()
+ {
+ DBManager::get()->execute(
+ "Update `forum_topics` SET `category_id` = null WHERE `category_id` = ?",
+ [$this->category_id]
+ );
+ }
+}
diff --git a/lib/models/Forum/ForumDiscussion.php b/lib/models/Forum/ForumDiscussion.php
new file mode 100644
index 0000000..53a0e4d
--- /dev/null
+++ b/lib/models/Forum/ForumDiscussion.php
@@ -0,0 +1,201 @@
+<?php
+namespace Forum;
+
+use DBManager;
+use SimpleORMap;
+use Forum\Service\DiscussionNotification;
+use Forum\DTO\ForumMember;
+use Forum\DTO\ForumTag;
+use User;
+
+/**
+ * @property string $discussion_id
+ * @property string $topic_id
+ * @property int $type_id
+ * @property string $title
+ * @property bool $sticky
+ * @property int $closed_at
+ * @property int $view_count
+ * @property int $mkdate
+ * @property int $chdate
+ *
+ * @property ForumTopic $topic
+ * @property ForumDiscussionType $discussion_type
+ * @property ForumPosting[] $postings
+ * @property ForumSubscription[] $subscribers
+ * @property User[] $users
+ * @property ForumMember[] $members
+ * @property ForumTag[] $tags
+ * @property ForumCategory $category
+ */
+class ForumDiscussion extends SimpleORMap
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_discussions';
+
+ $config['belongs_to']['topic'] = [
+ 'class_name' => ForumTopic::class,
+ 'foreign_key' => 'topic_id',
+ 'assoc_foreign_key' => 'topic_id'
+ ];
+
+ $config['belongs_to']['discussion_type'] = [
+ 'class_name' => ForumDiscussionType::class,
+ 'foreign_key' => 'type_id',
+ 'assoc_foreign_key' => 'type_id'
+ ];
+
+ $config['has_many']['postings'] = [
+ 'class_name' => ForumPosting::class,
+ 'foreign_key' => 'discussion_id',
+ 'assoc_foreign_key' => 'discussion_id'
+ ];
+
+ $config['has_many']['subscribers'] = [
+ 'class_name' => ForumSubscription::class,
+ 'foreign_key' => 'discussion_id',
+ 'assoc_foreign_key' => 'discussion_id'
+ ];
+
+ $config['additional_fields']['range_id']['get'] = 'getRangeId';
+ $config['additional_fields']['category']['get'] = 'getCategory';
+ $config['additional_fields']['tags']['get'] = 'getTags';
+ $config['additional_fields']['users']['get'] = 'getUsers';
+ $config['additional_fields']['members']['get'] = 'getMembers';
+ $config['additional_fields']['metadata']['get'] = 'getMetaData';
+
+ $config['registered_callbacks']['after_create'][] = 'onCreate';
+ $config['registered_callbacks']['after_delete'][] = 'onDelete';
+
+ parent::configure($config);
+ }
+
+ public function getTags()
+ {
+ return DBManager::get()->fetchAll(
+ "SELECT DISTINCT `tags_relations`.`tag_id`, `tags`.`name` FROM `tags`
+ LEFT JOIN `tags_relations` ON `tags`.`id` = `tags_relations`.`tag_id`
+ WHERE `tags_relations`.`range_id` = :discussion_id AND `tags`.`active` = TRUE
+ ORDER BY `tags`.`mkdate` DESC",
+ ['discussion_id' => $this->discussion_id],
+ function ($tag) {
+ return ForumTag::fromArray([
+ 'id' => $tag['tag_id'],
+ 'name' => $tag['name']
+ ]);
+ }
+ );
+ }
+
+ public function getCategory(): ?ForumCategory
+ {
+ return ForumCategory::findOneBySQL("JOIN forum_topics USING (category_id) WHERE forum_topics.topic_id = :topic_id", ['topic_id' => $this->topic_id]);
+ }
+
+ public function getRangeId(): string
+ {
+ return $this->topic->range_id;
+ }
+
+ public function getUsers($last_visit = null): array
+ {
+ $query = [
+ "JOIN forum_postings USING(user_id)
+ WHERE forum_postings.discussion_id = :discussion_id
+ AND forum_postings.anonymous = FALSE",
+ ['discussion_id' => $this->discussion_id]
+ ];
+
+ if ($last_visit) {
+ $query[0] .= " AND forum_postings.mkdate > :last_visit";
+ $query[1]["last_visit"] = $last_visit;
+ }
+
+ $users = User::findBySQL($query[0]." ORDER BY forum_postings.mkdate DESC", $query[1]);
+
+ $unique_users = [];
+ foreach ($users as $user) {
+ $unique_users[$user->user_id] = $user;
+ }
+
+ return array_values($unique_users);
+ }
+
+ public function getMembers($last_visit = null): array
+ {
+ $users = $this->getUsers($last_visit);
+
+ $members = [];
+ foreach ($users as $user) {
+ $members[] = ForumMember::fromUser($user, $this->range_id);
+ }
+
+ return $members;
+ }
+
+ public function transformData(): array
+ {
+ return [
+ 'discussion_id' => $this->discussion_id,
+ 'topic_id' => $this->topic_id,
+ 'type_id' => $this->type_id,
+ 'title' => $this->title,
+ 'sticky' => (int) $this->sticky,
+ 'closed_at' => $this->closed_at ? date('c', $this->closed_at) : '',
+ 'view_count' => (int) $this->view_count,
+ 'chdate' => date('c', $this->chdate),
+ 'mkdate' => date('c', $this->mkdate)
+ ];
+ }
+
+ public function getMetaData(): array
+ {
+ $user_id = \User::findCurrent()->user_id;
+ $object_user_visit = \ObjectUserVisit::findOneBySQL(
+ "object_id = :object_id AND plugin_id = :plugin_id AND user_id = :user_id",
+ [
+ 'object_id' => $this->topic->range_id,
+ 'plugin_id' => \PluginEngine::getPlugin(\CoreForum::class)->getPluginId(),
+ 'user_id' => $user_id,
+ ]
+ );
+
+ return DBManager::get()->fetchOne(
+ "SELECT
+ COUNT(`posting_id`) 'postings_count',
+ MAX(`mkdate`) 'recent_activity',
+ (
+ SELECT
+ `read_index`
+ FROM `forum_posting_reads`
+ WHERE `discussion_id` = :discussion_id AND `user_id` = :user_id
+ ) 'user_read_index',
+ (
+ SELECT
+ COUNT(DISTINCT fp.posting_id)
+ FROM forum_postings fp
+ WHERE fp.discussion_id = :discussion_id AND fp.mkdate > :last_visit
+ ) AS 'recent_postings_count'
+ FROM `forum_postings` WHERE `discussion_id` = :discussion_id",
+ [
+ 'discussion_id' => $this->discussion_id,
+ 'user_id' => $user_id,
+ 'last_visit' => $object_user_visit->last_visitdate ?? 0
+ ]
+ );
+ }
+
+ public function onCreate()
+ {
+ $discussionNotification = new DiscussionNotification($this);
+ $discussionNotification->notifySubscribers();
+ }
+
+ public function onDelete()
+ {
+ ForumSubscription::deleteBySQL("subject_id = ?", [$this->discussion_id]);
+ ForumPosting::deleteBySQL("discussion_id = ?", [$this->discussion_id]);
+ ForumPostingRead::deleteBySQL("discussion_id = ?", [$this->discussion_id]);
+ }
+}
diff --git a/lib/models/Forum/ForumDiscussionType.php b/lib/models/Forum/ForumDiscussionType.php
new file mode 100644
index 0000000..fe44add
--- /dev/null
+++ b/lib/models/Forum/ForumDiscussionType.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Forum;
+
+use SimpleORMap;
+
+/**
+ * @property string $type_id
+ * @property string $name
+ * @property string $icon
+ * @property int $mkdate
+ * @property int $chdate
+ *
+ * @property ForumDiscussion[] $discussions
+ */
+
+class ForumDiscussionType extends SimpleORMap
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_discussion_types';
+
+ $config['has_many']['discussions'] = [
+ 'class_name' => ForumDiscussion::class,
+ 'foreign_key' => 'type_id' ,
+ 'assoc_foreign_key' => 'type_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ public static function getForumDiscussionType(): array
+ {
+ return self::findBySQL("TRUE ORDER BY `mkdate` DESC");
+ }
+}
diff --git a/lib/models/Forum/ForumPosting.php b/lib/models/Forum/ForumPosting.php
new file mode 100644
index 0000000..bb14703
--- /dev/null
+++ b/lib/models/Forum/ForumPosting.php
@@ -0,0 +1,118 @@
+<?php
+namespace Forum;
+
+use SimpleORMap;
+use Forum\Service\PostingNotification;
+use User;
+use Forum\DTO\ForumMember;
+
+/**
+ * @property string $posting_id
+ * @property string $discussion_id
+ * @property string $range_id
+ * @property string $content
+ * @property boolean $anonymous
+ * @property int $mkdate
+ * @property int $chdate
+ *
+ * @property ForumDiscussion $discussion
+ * @property ForumPosting $posting
+ * @property ForumPostingReaction[] $reactions
+ * @property User $user
+ * @property ForumMember $author
+ */
+class ForumPosting extends SimpleORMap
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_postings';
+
+ $config['belongs_to']['discussion'] = [
+ 'class_name' => ForumDiscussion::class,
+ 'foreign_key' => 'discussion_id',
+ 'assoc_foreign_key' => 'discussion_id'
+ ];
+
+ $config['belongs_to']['posting'] = [
+ 'class_name' => ForumPosting::class,
+ 'foreign_key' => 'parent_id',
+ 'assoc_foreign_key' => 'posting_id'
+ ];
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id',
+ 'assoc_foreign_key' => 'user_id'
+ ];
+
+ $config['has_many']['reactions'] = [
+ 'class_name' => ForumPostingReaction::class,
+ 'foreign_key' => 'posting_id',
+ 'assoc_foreign_key' => 'posting_id'
+ ];
+
+ $config['additional_fields']['author']['get'] = 'getAuthor';
+ $config['registered_callbacks']['after_create'][] = 'onCreate';
+ $config['registered_callbacks']['after_delete'][] = 'onDelete';
+
+ parent::configure($config);
+ }
+
+ public function getAuthor(): ?ForumMember
+ {
+ if ($this->anonymous && $this->user_id !== User::findCurrent()->user_id) {
+ return ForumMember::fromArray();
+ }
+
+ $user = $this->user;
+ if ($user) {
+ return ForumMember::fromUser($user, $this->range_id);
+ }
+
+ return null;
+ }
+
+ public static function getRecentPosts($course_id, int $last_visit = 0): array
+ {
+ $query = [
+ "SELECT
+ forum_discussions.*,
+ COUNT(DISTINCT forum_postings.posting_id) AS 'posts'
+ FROM forum_topics
+ JOIN forum_discussions USING(topic_id)
+ JOIN forum_postings USING(discussion_id)
+ WHERE forum_topics.range_id = :course_id
+ ",
+ [
+ 'course_id' => $course_id
+ ]
+ ];
+
+ if ($last_visit) {
+ $query[0] .= " AND forum_postings.mkdate > :last_visit";
+ $query[1]["last_visit"] = $last_visit;
+ }
+
+ return \DBManager::get()->fetchAll(
+ $query[0]." GROUP BY discussion_id ORDER BY forum_postings.mkdate DESC",
+ $query[1]
+ );
+ }
+
+ public function getOpenGraphURLs()
+ {
+ $content = preg_replace("~<blockquote(.*?)>(.*)</blockquote>~si", '', $this['content']);
+ return \OpenGraph::extract($content);
+ }
+
+ public function onCreate()
+ {
+ $postingNotification = new PostingNotification($this);
+ $postingNotification->notifySubscribers();
+ }
+
+ public function onDelete()
+ {
+ ForumPostingReaction::deleteBySQL("posting_id = ?", [$this->posting_id]);
+ }
+}
diff --git a/lib/models/Forum/ForumPostingReaction.php b/lib/models/Forum/ForumPostingReaction.php
new file mode 100644
index 0000000..f7141a9
--- /dev/null
+++ b/lib/models/Forum/ForumPostingReaction.php
@@ -0,0 +1,49 @@
+<?php
+namespace Forum;
+
+use SimpleORMap;
+use User;
+
+/**
+ * @property int $id
+ * @property string $posting_id
+ * @property string $user_id
+ * @property string $emoji
+ * @property int $mkdate
+ * @property int $chdate
+ *
+ * @property ForumPosting $posting
+ * @property User $user
+ */
+
+class ForumPostingReaction extends SimpleORMap
+{
+ public const thumbUp = 'THUMBS UP SIGN';
+ public const thumbDown = 'THUMBS DOWN SIGN';
+ public const rocket = 'ROCKET';
+ public const grinningFace = 'GRINNING FACE';
+ public const sunglasses = 'SMILING FACE WITH SUNGLASSES';
+ public const confused = 'CONFUSED FACE';
+ public const heart = 'BLACK HEART SUIT';
+ public const party = 'PARTY POPPER';
+
+
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_posting_reactions';
+
+ $config['belongs_to']['posting'] = [
+ 'class_name' => ForumPosting::class,
+ 'foreign_key' => 'posting_id',
+ 'assoc_foreign_key' => 'posting_id'
+ ];
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id',
+ 'assoc_foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+}
diff --git a/lib/models/Forum/ForumPostingRead.php b/lib/models/Forum/ForumPostingRead.php
new file mode 100644
index 0000000..8232236
--- /dev/null
+++ b/lib/models/Forum/ForumPostingRead.php
@@ -0,0 +1,64 @@
+<?php
+namespace Forum;
+
+use SimpleORMap;
+use User;
+
+/**
+ * @property string $discussion_id
+ * @property string $user_id
+ * @property int $read_index
+ * @property int $chdate
+ *
+ * @property ForumDiscussion $discussion
+ * @property User $users
+ */
+
+class ForumPostingRead extends SimpleORMap
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_posting_reads';
+
+ $config['belongs_to']['discussion'] = [
+ 'class_name' => ForumDiscussion::class,
+ 'foreign_key' => 'discussion_id',
+ 'assoc_foreign_key' => 'discussion_id'
+ ];
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id',
+ 'assoc_foreign_key' => 'user_id'
+ ];
+
+ parent::configure($config);
+ }
+
+ public static function updateUserReadPoint($user_id, $discussion_id, int $read_index = 0): ForumPostingRead
+ {
+ $postingRead = ForumPostingRead::findOneBySQL(
+ "discussion_id = :discussion_id AND user_id = :user_id",
+ [
+ 'discussion_id' => $discussion_id,
+ 'user_id' => $user_id
+ ]
+ );
+
+ if (!$postingRead) {
+ $postingRead = new ForumPostingRead();
+ $postingRead->discussion_id = $discussion_id;
+ $postingRead->user_id = $user_id;
+ }
+
+ if (!$read_index) {
+ $read_index = $postingRead->read_index + 1;
+ }
+
+ $postingRead->read_index = $read_index;
+
+ $postingRead->store();
+
+ return $postingRead;
+ }
+}
diff --git a/lib/models/Forum/ForumSubscription.php b/lib/models/Forum/ForumSubscription.php
new file mode 100644
index 0000000..a805975
--- /dev/null
+++ b/lib/models/Forum/ForumSubscription.php
@@ -0,0 +1,53 @@
+<?php
+namespace Forum;
+
+use Course;
+use SimpleORMap;
+use User;
+use Forum\Enum\SubscriptionNotificationType;
+
+/**
+ * @property int $id
+ * @property string $subject_id
+ * @property string $range_id
+ * @property string $subject
+ * @property SubscriptionNotificationType $notification_type
+ * @property int $mkdate
+ * @property int $chdate
+ *
+ * @property ForumDiscussion | ForumTopic $subject_object
+ * @property User $user
+ * @property Course $range
+ */
+
+class ForumSubscription extends SimpleORMap
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_subscriptions';
+
+ $config['belongs_to']['user'] = [
+ 'class_name' => User::class,
+ 'foreign_key' => 'user_id',
+ 'assoc_foreign_key' => 'user_id'
+ ];
+
+ $config['belongs_to']['range'] = [
+ 'class_name' => Course::class,
+ 'foreign_key' => 'range_id',
+ 'assoc_foreign_key' => 'Seminar_id'
+ ];
+
+ $config['additional_fields']['subject_object']['get'] = 'getSubjectObject';
+
+ parent::configure($config);
+ }
+
+ public function getSubjectObject(): ForumDiscussion | ForumTopic
+ {
+ return match ($this->subject) {
+ 'topic' => ForumTopic::find($this->subject_id),
+ 'discussion' => ForumDiscussion::find($this->subject_id)
+ };
+ }
+}
diff --git a/lib/models/Forum/ForumTopic.php b/lib/models/Forum/ForumTopic.php
new file mode 100644
index 0000000..42227fd
--- /dev/null
+++ b/lib/models/Forum/ForumTopic.php
@@ -0,0 +1,153 @@
+<?php
+namespace Forum;
+
+use DBManager;
+use SimpleORMap;
+use User;
+
+/**
+ * @property string $topic_id
+ * @property string $category_id
+ * @property string $range_id
+ * @property string $name
+ * @property string $description
+ * @property int $position
+ * @property int $mkdate
+ * @property int $chdate
+ *
+ * @property ForumCategory $category
+ * @property ForumDiscussion[] $discussions
+ * @property User[] $users
+ * @property array $metadata
+ */
+
+class ForumTopic extends SimpleORMap
+{
+ protected static function configure($config = [])
+ {
+ $config['db_table'] = 'forum_topics';
+
+ $config['belongs_to']['category'] = [
+ 'class_name' => ForumCategory::class,
+ 'foreign_key' => 'category_id',
+ 'assoc_foreign_key' => 'category_id'
+ ];
+
+ $config['has_many']['discussions'] = [
+ 'class_name' => ForumDiscussion::class,
+ 'foreign_key' => 'topic_id',
+ 'assoc_foreign_key' => 'topic_id',
+ ];
+
+ $config['additional_fields']['users']['get'] = 'getUsers';
+ $config['additional_fields']['metadata']['get'] = 'getMetaData';
+ $config['registered_callbacks']['after_delete'][] = 'onDelete';
+
+ parent::configure($config);
+ }
+
+ public static function getCourseTopics($course_id)
+ {
+ return self::findBySQL(
+ "range_id = :course_id
+ GROUP BY CASE WHEN category_id IS NULL THEN topic_id ELSE category_id END
+ ORDER BY position ASC, mkdate DESC",
+ ["course_id" => $course_id]
+ );
+ }
+
+ public static function getCourseTopic($course_id, $topic_id)
+ {
+ return self::findOneBySQL("range_id = ? AND topic_id = ?", [$course_id, $topic_id]);
+ }
+
+ public function getUsers($last_visit = null): array
+ {
+ $query = [
+ "JOIN forum_postings USING(user_id)
+ JOIN forum_discussions USING(discussion_id)
+ WHERE forum_discussions.topic_id = :topic_id ",
+ ['topic_id' => $this->topic_id]
+ ];
+
+ if ($last_visit) {
+ $query[0] .= " AND forum_postings.mkdate > :last_visit";
+ $query[1]["last_visit"] = $last_visit;
+ }
+
+ $users = User::findBySQL($query[0]." ORDER BY forum_postings.mkdate DESC", $query[1]);
+
+ $unique_users = [];
+ foreach ($users as $user) {
+ $unique_users[$user->user_id] = $user;
+ }
+
+ return array_values($unique_users);
+ }
+
+ public function getMetaData(): array
+ {
+ $user_id = User::findCurrent()->user_id;
+ $object_user_visit = \ObjectUserVisit::findOneBySQL(
+ "object_id = :object_id AND plugin_id = :plugin_id AND user_id = :user_id",
+ [
+ 'object_id' => $this->range_id,
+ 'plugin_id' => \PluginEngine::getPlugin(\CoreForum::class)->getPluginId(),
+ 'user_id' => $user_id,
+ ]
+ );
+
+ return DBManager::get()->fetchOne(
+ "SELECT
+ COUNT(DISTINCT `forum_discussions`.`discussion_id`) AS 'discussions_count',
+ COUNT(DISTINCT `forum_postings`.`posting_id`) AS 'postings_count',
+ COUNT(DISTINCT `forum_postings`.`user_id`) AS 'users_count',
+ MAX(`forum_postings`.`mkdate`) AS 'recent_activity',
+ (
+ SELECT
+ SUM(fpr.read_index)
+ FROM forum_discussions fd2
+ JOIN forum_posting_reads fpr
+ ON fpr.discussion_id = fd2.discussion_id
+ AND fpr.user_id = :user_id
+ WHERE fd2.topic_id = :topic_id
+ ) AS 'user_read_index',
+ (
+ SELECT
+ COUNT(DISTINCT fp.posting_id)
+ FROM forum_topics ft
+ JOIN forum_discussions fd USING(topic_id)
+ JOIN forum_postings fp ON fp.discussion_id = fd.discussion_id AND fp.mkdate > :last_visit
+ WHERE ft.topic_id = :topic_id
+ ) AS 'recent_postings_count'
+ FROM `forum_discussions`
+ LEFT JOIN `forum_postings` USING (`discussion_id`)
+ WHERE `forum_discussions`.`topic_id` = :topic_id",
+ [
+ 'topic_id' => $this->topic_id,
+ 'user_id' => $user_id,
+ 'last_visit' => $object_user_visit->last_visitdate ?? 0
+ ]
+ );
+ }
+
+ public function transformData(): array
+ {
+ return [
+ 'topic_id' => $this->topic_id,
+ 'category_id' => $this->category_id,
+ 'range_id' => $this->range_id,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'position' => $this->position,
+ 'chdate' => date('c', $this->chdate),
+ 'mkdate' => date('c', $this->mkdate)
+ ];
+ }
+
+ public function onDelete()
+ {
+ ForumSubscription::deleteBySQL("subject_id = ?", [$this->topic_id]);
+ ForumDiscussion::deleteBySQL("topic_id = ?", [$this->topic_id]);
+ }
+}
diff --git a/lib/models/ForumCat.php b/lib/models/ForumCat.php
deleted file mode 100644
index 069f097..0000000
--- a/lib/models/ForumCat.php
+++ /dev/null
@@ -1,258 +0,0 @@
-<?php
-/**
- * ForumCat.php - Class to handle categories for areas
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- *
- * @property string $id alias column for category_id
- * @property string $category_id database column
- * @property string $seminar_id database column
- * @property string $entry_name database column
- * @property int $pos database column
- */
-
-class ForumCat extends SimpleORMap
-{
- /**
- * Configures this model.
- *
- * @param array $config Configuration array
- */
- protected static function configure($config = [])
- {
- $config['db_table'] = 'forum_categories';
- parent::configure($config);
- }
-
- /**
- * Return a list of all areas with their categories.
- * Empty categories are excluded by default
- *
- * @param string $seminar_id the seminar_id the retrieve the categories for
- * @param string $exclude_null if false, empty categories are returned as well
- * @return array list of categories
- */
- public static function getListWithAreas($seminar_id, $exclude_null = true)
- {
- $stmt = DBManager::get()->prepare("SELECT * FROM forum_categories AS fc
- LEFT JOIN forum_categories_entries AS fce USING (category_id)
- WHERE seminar_id = ? "
- . ($exclude_null ? 'AND fce.topic_id IS NOT NULL ' : '')
- . "ORDER BY fc.pos ASC, fce.pos ASC");
-
- $stmt->execute([$seminar_id]);
-
- return $stmt->fetchAll(PDO::FETCH_ASSOC);
- }
-
- /**
- * Returns the name of the associated category for an area denoted by the
- * passed topic_id
- *
- * @param string $topic_id
- * @return string the name of the category
- */
- public static function getCategoryNameForArea($topic_id)
- {
- $stmt = DBManager::get()->prepare("SELECT fc.entry_name FROM forum_categories AS fc
- LEFT JOIN forum_categories_entries AS fce USING (category_id)
- WHERE fce.topic_id = ?");
- $stmt->execute([$topic_id]);
-
- return $stmt->fetchColumn();
- }
-
-
- /**
- * Adds a new category with the passed name to the passed seminar and
- * returns the id of the newly created category
- *
- * @param string $seminar_id
- * @param string $name the name of the new category
- * @return string the id of the newly created category
- */
- public static function add($seminar_id, $name)
- {
- $stmt = DBManager::get()->prepare("INSERT INTO forum_categories
- (category_id, seminar_id, entry_name)
- VALUES (?, ?, ?)");
-
- $category_id = md5(uniqid(rand()));
-
- $stmt->execute([$category_id, $seminar_id, $name]);
-
- return $category_id;
- }
-
-
- /**
- * Remove the category with the passed id. The seminar_id is used only
- * to be certain.
- *
- * @param string $category_id The ID of the category to be deleted
- * @param string $seminar_id Seminar-ID the category belongs to
- */
- public static function remove($category_id, $seminar_id)
- {
- // delete the category itself
- $stmt = DBManager::get()->prepare("DELETE FROM
- forum_categories
- WHERE category_id = ?");
- $stmt->execute([$category_id]);
-
- // set all entries to default category
- $stmt = DBManager::get()->prepare("UPDATE
- forum_categories_entries
- SET category_id = ?, pos = 999
- WHERE category_id = ?");
- $stmt->execute([$seminar_id, $category_id]);
- }
-
-
- /**
- * Set the position for the passed category to the passed value
- *
- * @param string $category_id the ID of the category to update
- * @param int $pos the new position
- */
- public static function setPosition($category_id, $pos)
- {
- $stmt = DBManager::get()->prepare("UPDATE
- forum_categories
- SET pos = ? WHERE category_id = ?");
- $stmt->execute([$pos, $category_id]);
- }
-
-
- /**
- * Add the passed area to the passed category and remove it from all
- * other categories.
- *
- * @param string $category_id the ID of the category
- * @param string $area_id the ID of the area to add the category to
- */
- public static function addArea($category_id, $area_id)
- {
- // remove area from all other categories
- $stmt = DBManager::get()->prepare("DELETE FROM
- forum_categories_entries
- WHERE topic_id = ?");
- $stmt->execute([$area_id]);
-
- // add area to this category, make sure it is at the end
- $stmt = DBManager::get()->prepare("SELECT COUNT(*) FROM
- forum_categories_entries
- WHERE category_id = ?");
- $stmt->execute([$category_id]);
- $new_pos = $stmt->fetchColumn() + 1;
-
- $stmt = DBManager::get()->prepare("REPLACE INTO
- forum_categories_entries
- (category_id, topic_id, pos) VALUES (?, ?, ?)");
- $stmt->execute([$category_id, $area_id, $new_pos]);
- }
-
-
- /**
- * Remove the passed area from all categories.
- *
- * @param string $area_id the ID of the area to be removed
- */
- public static function removeArea($area_id)
- {
- $stmt = DBManager::get()->prepare("DELETE FROM
- forum_categories_entries
- WHERE topic_id = ?");
- $stmt->execute([$area_id]);
- }
-
-
- /**
- * Set the position for the passed category to the passed value
- *
- * @param string $area_id the ID of the area to update
- * @param int $pos the new position
- */
- public static function setAreaPosition($area_id, $pos)
- {
- $stmt = DBManager::get()->prepare("UPDATE
- forum_categories_entries
- SET pos = ? WHERE topic_id = ?");
- $stmt->execute([$pos, $area_id]);
- }
-
-
- /**
- * Set the name for the passed category
- *
- * @param string $category_id the ID of the category to update
- * @param string $name the name to set
- */
- public static function setName($category_id, $name)
- {
- $stmt = DBManager::get()->prepare("UPDATE
- forum_categories
- SET entry_name = ? WHERE category_id = ?");
- $stmt->execute([$name, $category_id]);
- }
-
- /**
- * Return the data for the passed category_id
- *
- * @param string $category_id
- *
- * @return array the data for the passed category_id
- */
- public static function get($category_id)
- {
- $stmt = DBManager::get()->prepare("SELECT * FROM forum_categories
- WHERE category_id = ?");
- $stmt->execute([$category_id]);
-
- return $stmt->fetch(PDO::FETCH_ASSOC);
- }
-
- /**
- * Return the areas for the passed category_id
- *
- * @param string $category_id
- * @param int $start limit start (optional)
- * @param int $num number of entries to fetch (optional, default is 20)
- *
- * @return array the data for the passed category_id
- */
- public static function getAreas($category_id, $start = null, $num = 20)
- {
- $category = self::get($category_id);
-
- $limit = '';
- if ($start !== null && $num) {
- $limit = " LIMIT $start, $num";
- }
-
- if ($category_id == $category['seminar_id']) {
- $stmt = DBManager::get()->prepare("SELECT fe.* FROM forum_entries AS fe
- LEFT JOIN forum_categories_entries AS fce USING (topic_id)
- WHERE seminar_id = ? AND depth = 1 AND (
- fce.category_id = ? OR fce.category_id IS NULL
- ) ORDER BY category_id DESC, pos ASC" . $limit);
- $stmt->execute([$category_id, $category_id]);
- } else {
- $stmt = DBManager::get()->prepare("SELECT forum_entries.* FROM forum_categories_entries
- LEFT JOIN forum_entries USING(topic_id)
- WHERE category_id = ?
- ORDER BY pos ASC" . $limit);
-
- $stmt->execute([$category_id]);
- }
-
- return $stmt->fetchAll(PDO::FETCH_ASSOC);
- }
-}
diff --git a/lib/models/PersonalNotifications.php b/lib/models/PersonalNotifications.php
index 1d753eb..36a6187 100644
--- a/lib/models/PersonalNotifications.php
+++ b/lib/models/PersonalNotifications.php
@@ -98,7 +98,7 @@ class PersonalNotifications extends SimpleORMap
* this html-element the notification will be marked as read, so the user
* does not need to handle the information twice. Optional. Default: null
* @param null|Icon|string $avatar : either an Icon or a URL of an
- * image for the notification. Best size: 40px x 40px
+ * image for the notification. Best size: 40px x 40px or an HTML emoji code.
* @return boolean : true on success
*/
public static function add($user_ids, $url, $text, $html_id = null, $avatar = null, $dialog = false)
diff --git a/lib/models/User.php b/lib/models/User.php
index 18ab436..af4f75f 100644
--- a/lib/models/User.php
+++ b/lib/models/User.php
@@ -1203,11 +1203,6 @@ class User extends AuthUserMd5 implements Range, PrivacyObject, Studip\Calendar\
// Restliche Daten übertragen
- // ForumsModule migrieren
- foreach (PluginEngine::getPlugins(ForumModule::class) as $plugin) {
- $plugin->migrateUser($old_id, $new_id);
- }
-
// Dateieintragungen und Ordner
// TODO (mlunzena) should post a notification
$query = "UPDATE IGNORE file_refs SET user_id = ? WHERE user_id = ?";
diff --git a/lib/modules/CoreForum.php b/lib/modules/CoreForum.php
index c9a23d1..0c2b5e5 100644
--- a/lib/modules/CoreForum.php
+++ b/lib/modules/CoreForum.php
@@ -1,189 +1,92 @@
<?php
-/*
- * Forum.php - Forum
+/**
+ * Forum: Discussion of specific topics within courses
*
- * 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 Till Glöggler <till.gloeggler@elan-ev.de>
- * @copyright 2011 ELAN e.V. <http://www.elan-ev.de>
- * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
- * @category Stud.IP
+ * @author Murtaza Sultani <sultani@data-quest.de>
+ * @author Rasmus Fuhse <fuhse@data-quest.de>
+ * @license GPL2 or any later version
+ * @since Stud.IP 6.1
*/
-// Notifications
-NotificationCenter::addObserver('CoreForum', 'overviewDidClear', 'OverviewDidClear');
-NotificationCenter::addObserver('CoreForum', 'removeAbosForUserAndCourse', 'UserDidLeaveCourse');
-
-NotificationCenter::addObserver('ForumActivity', 'newEntry', 'ForumAfterInsert');
-NotificationCenter::addObserver('ForumActivity', 'updateEntry', 'ForumAfterUpdate');
-NotificationCenter::addObserver('ForumActivity', 'deleteEntry', 'ForumBeforeDelete');
+use Forum\ForumPosting;
-NotificationCenter::addObserver('ForumIssue', 'unlinkIssue', 'ForumBeforeDelete');
-
-class CoreForum extends CorePlugin implements ForumModule
+class CoreForum extends CorePlugin implements StudipModule
{
- /* interface method */
public function getTabNavigation($course_id)
{
- $navigation = new Navigation(_('Forum'), 'dispatch.php/course/forum/index');
- $navigation->setImage(Icon::create('forum', 'info_alt'));
+ $navigation = new Navigation(_('Forum'), 'dispatch.php/course/forum/topics');
- // add main third-level navigation-item
- $navigation->addSubNavigation('index', new Navigation(_('Übersicht'), 'dispatch.php/course/forum/index'));
+ $navigation->setImage(Icon::create('forum', 'info_alt'));
- if (ForumPerm::has('fav_entry', $course_id)) {
- $navigation->addSubNavigation('newest', new Navigation(_("Neue Beiträge"), 'dispatch.php/course/forum/index/newest'));
- $navigation->addSubNavigation('latest', new Navigation(_("Letzte Beiträge"), 'dispatch.php/course/forum/index/latest'));
- $navigation->addSubNavigation('favorites', new Navigation(_('Gemerkte Beiträge'), 'dispatch.php/course/forum/index/favorites'));
+ $navigation->addSubNavigation(
+ 'topics',
+ new Navigation(_('Themenübersicht'), 'dispatch.php/course/forum/topics')
+ );
- // mass-administrate the forum
- if (ForumPerm::has('admin', $course_id)) {
- $navigation->addSubNavigation('admin', new Navigation(_('Administration'), 'dispatch.php/course/forum/admin'));
- }
+ if (!CourseConfig::get($course_id)->FORUM_HIDE_CATEGORIES_NAVIGATION) {
+ $navigation->addSubNavigation(
+ 'categories',
+ new Navigation(_('Kategorien'), 'dispatch.php/course/forum/categories')
+ );
}
- return ['forum2' => $navigation];
+ $navigation->addSubNavigation(
+ 'subscriptions',
+ new Navigation(_('Abonnierte Diskussionen'), 'dispatch.php/course/forum/subscriptions')
+ );
+
+ return ['forum' => $navigation];
}
- /* interface method */
- public function getIconNavigation($course_id, $last_visit, $user_id = null)
+ public function getIconNavigation($course_id, $last_visit, $user_id)
{
+ $recent_posts_count = 0;
+ $navigation_title = _('Forum');
+
if ($GLOBALS['perm']->have_studip_perm('user', $course_id)) {
- $num_entries = ForumVisit::getCount($course_id, ForumVisit::getVisit($course_id));
- $text = ForumHelpers::getVisitText($num_entries, $course_id);
- } else {
- $num_entries = 0;
- $text = 'Forum';
+ $recent_posts = ForumPosting::getRecentPosts($course_id, $last_visit);
+ $recent_posts_count = array_sum(array_column($recent_posts, 'posts'));
+
+ if ($recent_posts_count > 0) {
+ $navigation_title = sprintf(_('%s neue Beiträge seit Ihrem letzten Besuch.'), $recent_posts_count);
+ } else {
+ $navigation_title = _('Keine neuen Beiträge seit Ihrem letzten Besuch.');
+ }
}
- $navigation = new Navigation('forum', 'dispatch.php/course/forum/index/enter_seminar');
- $navigation->setBadgeNumber($num_entries);
- $navigation->setLinkAttributes(['title' => $text]);
+ $navigation = new Navigation(_("Forum"));
+ $navigation->setBadgeNumber($recent_posts_count);
+
+ $navigation->setLinkAttributes(['title' => $navigation_title]);
- if ($num_entries > 0) {
+ if ($recent_posts_count > 0) {
$navigation->setImage(Icon::create('forum', Icon::ROLE_ATTENTION));
+ $navigation->setURL('dispatch.php/course/forum/recent', ['last_visit' => $last_visit]);
} else {
$navigation->setImage(Icon::create('forum'));
+ $navigation->setURL('dispatch.php/course/forum/topics');
}
return $navigation;
}
- /**
- * This method is called, whenever an user clicked to clear the visit timestamps
- * and set everything as visited
- *
- * @param object $notification
- * @param string $user_id
- */
- public static function overviewDidClear($notification, $user_id)
- {
- $query = "REPLACE INTO `forum_visits`
- SELECT `user_id`, `Seminar_id`, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
- FROM `seminar_user`
- WHERE `user_id` = ?";
- DBManager::get()->execute($query, [$user_id]);
- }
-
- /**
- * This method is called whenever a user is removed from a course and thus
- * the forum abos will be removed.
- *
- * @param object $notification
- * @param string $course_id
- * @param string $user_id
- */
- public static function removeAbosForUserAndCourse($notification, $course_id, $user_id)
- {
- ForumAbo::removeForCourseAndUser($course_id, $user_id);
- }
-
public function getInfoTemplate($course_id)
{
- return null;
- }
-
- /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
- /* * IMPLEMENTATION OF METHODS FROM FORUMMODULE-INTERFACE * */
- /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
-
- public function getLinkToThread($issue_id)
- {
- if ($topic_id = ForumIssue::getThreadIdForIssue($issue_id)) {
- return URLHelper::getLink('dispatch.php/course/forum/index/index/' . $topic_id);
- }
-
- return false;
- }
-
- public function setThreadForIssue($issue_id, $title, $content)
- {
- ForumIssue::setThreadForIssue(Context::getId(), $issue_id, $title, $content);
- }
-
- public function getNumberOfPostingsForUser($user_id, $seminar_id = null)
- {
- return ForumEntry::countUserEntries($user_id, $seminar_id);
- }
-
- public function getNumberOfPostingsForIssue($issue_id)
- {
- $topic_id = ForumIssue::getThreadIdForIssue($issue_id);
-
- return $topic_id ? ForumEntry::countEntries($topic_id) : 0;
- }
-
- public function getNumberOfPostingsForSeminar($seminar_id)
- {
- return floor(ForumEntry::countEntries($seminar_id));
- }
-
- public function getNumberOfPostings()
- {
- return ForumEntry::countAllEntries();
- }
-
- public function getEntryTableInfo()
- {
- return [
- 'table' => 'forum_entries',
- 'content' => 'content',
- 'chdate' => 'chdate',
- 'seminar_id' => 'seminar_id',
- 'user_id' => 'user_id'
- ];
+ // TODO: Implement getInfoTemplate() method.
}
- public function getTopTenSeminars()
+ public static function isAdmin($course_id): bool
{
- return ForumEntry::getTopTenSeminars();
+ return $GLOBALS['perm']->have_perm('root') || $GLOBALS['perm']->have_studip_perm('dozent', $course_id);
}
- public function migrateUser($user_from, $user_to)
+ public static function isModerator($course_id): bool
{
- ForumEntry::migrateUser($user_from, $user_to);
+ return self::isAdmin($course_id) ||
+ CourseConfig::get($course_id)->FORUM_MODERATION_PERMISSION === $GLOBALS['perm']->get_studip_perm($course_id) ||
+ CourseConfig::get($course_id)->FORUM_MODERATION_PERMISSION === 'all';
}
- public function deleteContents($seminar_id)
- {
- ForumEntry::delete($seminar_id);
- }
-
- public function getDump($seminar_id)
- {
- return ForumEntry::getDump($seminar_id);
- }
-
- public static function getDescription()
- {
- return _('Textbasierte und zeit- und ortsunabhängige '.
- 'Diskursmöglichkeit. Lehrende können parallel zu '.
- 'Veranstaltungsthemen Fragen stellen, die von den Studierenden '.
- 'per Meinungsaustausch besprochen werden.');
- }
/**
* {@inheritdoc}
@@ -201,11 +104,17 @@ class CoreForum extends CorePlugin implements ForumModule
'screenshots' => [
'path' => 'assets/images/plus/screenshots/Forum',
'pictures' => [
- 0 => ['source' => 'Uebersicht.jpg', 'title' => _('Übersicht')],
- 1 => ['source' => 'Beitrag.jpg', 'title' => _('Beitrag')],
- 2 => ['source' => 'Beitrag_verfassen.jpg', 'title' => _('Beitrag verfassen')],
+ ['source' => 'Lehrendensicht_-_Kategorien_mit_Bereichen_und_Beitraegen.jpg'],
+ ['source' => 'Studentische_Sicht_-_Kategorien_mit_Bereichen_und_Beitraegen.jpg'],
+ ['source' => 'Einen_Forumsbeitrag_erstellen.jpg'],
]
]
];
}
+
+ public static function deleteCourseContents($course_id)
+ {
+ \Forum\ForumCategory::deleteBySQL("range_id = ?", [$course_id]);
+ \Forum\ForumTopic::deleteBySQL("range_id = ?", [$course_id]);
+ }
}
diff --git a/lib/navigation/AdminNavigation.php b/lib/navigation/AdminNavigation.php
index fe31b72..3c92b62 100644
--- a/lib/navigation/AdminNavigation.php
+++ b/lib/navigation/AdminNavigation.php
@@ -176,6 +176,14 @@ class AdminNavigation extends Navigation
'dispatch.php/admin/tags/index'
)
);
+
+ $navigation->addSubNavigation(
+ 'forum_discussion_types',
+ new Navigation(
+ _('Forum (Diskussionstypen)'),
+ 'dispatch.php/course/forum/discussion_types'
+ )
+ );
}
$this->addSubNavigation('locations', $navigation);
diff --git a/lib/object.inc.php b/lib/object.inc.php
index a8bd5d7..a053bf4 100644
--- a/lib/object.inc.php
+++ b/lib/object.inc.php
@@ -359,6 +359,7 @@ function object_type_to_id($type)
'schedule' => 'CoreSchedule',
'scm' => 'CoreScm',
'wiki' => 'CoreWiki',
+ 'forum' => 'CoreForum',
'elearning_interface' => 'CoreElearningInterface',
'ilias_interface' => 'IliasInterfaceModule',
'participants' => 'CoreParticipants'
@@ -397,6 +398,7 @@ function object_id_to_type($id)
'schedule' => 'CoreSchedule',
'scm' => 'CoreScm',
'wiki' => 'CoreWiki',
+ 'forum' => 'CoreForum',
'elearning_interface' => 'CoreElearningInterface',
'ilias_interface' => 'IliasInterfaceModule',
'participants' => 'CoreParticipants'
diff --git a/lib/plugins/core/ForumModule.php b/lib/plugins/core/ForumModule.php
deleted file mode 100644
index e72ffc2..0000000
--- a/lib/plugins/core/ForumModule.php
+++ /dev/null
@@ -1,140 +0,0 @@
-<?php
-/**
- * ForumModule.php - Interface for all intersections between the Stud.IP
- * Core and something that behaves like a forum
- *
- * Implement all interface methods and you can integrate your plugin like
- * a real core-module into Stud.IP
- *
- * 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 3 of
- * the License, or (at your option) any later version.
- *
- * @author Till Glöggler <tgloeggl@uos.de>
- * @license http://www.gnu.org/licenses/gpl-3.0.html GPL version 3
- * @category Stud.IP
- */
-
-interface ForumModule extends StandardPlugin
-{
- /**
- * Issues can be connected with an entry in a forum. This method
- * has to return an url to the connected topic for the passed issue_id.
- * If no topic is connected, it has to return "false"
- *
- * @param string $issue_id
- * @return mixed URL or false
- */
- function getLinkToThread($issue_id);
-
- /**
- * This method is called in case of an creation OR an update of an issue.
- * Normally one would update the title and the content of the linked topic
- * when called
- *
- * @param string $issue_id
- * @param string $title the title of the issue
- * @param string $content the description of the issue
- */
- function setThreadForIssue($issue_id, $title, $content);
-
- /**
- * Return the number of postings the connected topic contains for
- * the issue with the passed id
- *
- * @param type $issue_id
- *
- * @return int
- */
- function getNumberOfPostingsForIssue($issue_id);
-
- /**
- * Return the number of postings for the passed user
- *
- * @param type $user_id
- *
- * @return int
- */
- function getNumberOfPostingsForUser($user_id);
-
- /**
- * Return the number of postings for the passed seminar
- *
- * @param type $seminar_id
- *
- * @return int
- */
- function getNumberOfPostingsForSeminar($seminar_id);
-
- /**
- * Return the number of all postings served by your module. The
- * results are used for statistics.
- *
- * @return int
- */
- function getNumberOfPostings();
-
- /**
- * This function is called whenever Stud.IP needs to directly operate
- * on your entries-table. Your entries-table MUST have at least fields
- * for a date (a change-date is preferred, but make-date will suffice),
- * posting-content, seminar_id and user_id.
- *
- * The returning array must have the following structure:
- * Array (
- * 'table' => 'your_entry_table,
- * 'content' => 'your_content_field',
- * 'chdate' => 'your_date_field',
- * 'seminar_id' => 'your_seminar_id_field',
- * 'user_id' => 'your_user_id_field'
- * )
- *
- * @return array
- */
- function getEntryTableInfo();
-
- /**
- * The caller expects an array of the ten seminars with the most postings
- * in your module.
- *
- * Return an array of the following structure:
- * Array (
- * Array (
- * 'seminar_id' =>
- * 'display' =>
- * 'count' =>
- * )
- * )
- *
- * @return array
- */
- function getTopTenSeminars();
-
- /**
- * Is called when the data of a user is moved to another user.
- * Update all user_ids with the passed new one.
- *
- * @param string $user_from the user_id of the user who has the data
- * @param string $user_to the user_id of the user who shall receive the data
- */
- function migrateUser($user_from, $user_to);
-
- /**
- * Clean up everything for the passed seminar, because the seminar
- * is beeing deleted.
- *
- * @param string $seminar_id
- */
- function deleteContents($seminar_id);
-
- /**
- * Return a complete HTML-Dump of all entries in the forum-module. This is
- * used for archiving purposes, so make it pretty!
- *
- * @param string $seminar_id
- *
- * @return string a single-page HTML-view of all contents in one string
- */
- function getDump($seminar_id);
-}
diff --git a/package-lock.json b/package-lock.json
index c552be9..1c56c59 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "6.1.0",
"license": "GPL-2.0",
"dependencies": {
- "@vojtechlanka/vue-tags-input": "^3.1.1"
+ "@vojtechlanka/vue-tags-input": "^3.1.1",
+ "jsonapi-serializer": "^3.6.9"
},
"devDependencies": {
"@axe-core/playwright": "^4.6.1",
@@ -6367,7 +6368,6 @@
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"dev": true,
- "optional": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -6381,7 +6381,6 @@
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
"dev": true,
- "optional": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"get-intrinsic": "^1.2.6"
@@ -7008,43 +7007,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/copy-webpack-plugin": {
- "version": "13.0.0",
- "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz",
- "integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "glob-parent": "^6.0.1",
- "normalize-path": "^3.0.0",
- "schema-utils": "^4.2.0",
- "serialize-javascript": "^6.0.2",
- "tinyglobby": "^0.2.12"
- },
- "engines": {
- "node": ">= 18.12.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/webpack"
- },
- "peerDependencies": {
- "webpack": "^5.1.0"
- }
- },
- "node_modules/copy-webpack-plugin/node_modules/glob-parent": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
- "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "is-glob": "^4.0.3"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
"node_modules/core-js": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.31.0.tgz",
@@ -8216,7 +8178,6 @@
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
- "optional": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -8435,7 +8396,6 @@
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
- "optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -8445,7 +8405,6 @@
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
- "optional": true,
"engines": {
"node": ">= 0.4"
}
@@ -8461,7 +8420,6 @@
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
- "optional": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -9539,7 +9497,6 @@
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
"dev": true,
- "optional": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-define-property": "^1.0.1",
@@ -9586,7 +9543,6 @@
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
- "optional": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -9764,7 +9720,6 @@
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
- "optional": true,
"engines": {
"node": ">= 0.4"
},
@@ -9844,7 +9799,6 @@
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
- "optional": true,
"engines": {
"node": ">= 0.4"
},
@@ -9880,7 +9834,6 @@
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
- "optional": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -10189,6 +10142,12 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inflected": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/inflected/-/inflected-1.1.7.tgz",
+ "integrity": "sha512-3lz7idKIPmKvz0wqlu1PUPSg5strJnCh2v2NldPQy13Fmd6WsWQ5yExDoiIX48lQ9mo8N7ztdDlkZxOauZ/E5g==",
+ "license": "MIT"
+ },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -11378,6 +11337,19 @@
"node": ">=6"
}
},
+ "node_modules/jsonapi-serializer": {
+ "version": "3.6.9",
+ "resolved": "https://registry.npmjs.org/jsonapi-serializer/-/jsonapi-serializer-3.6.9.tgz",
+ "integrity": "sha512-LeRPlP93Mz6+Klu13OKcnXNLvtH1gbeo/yfThqihAMw7vUBCWWs6jHImpR/tQwzAxJi7F1+bfVJxeHoNCrbZiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "inflected": "^1.1.6",
+ "lodash": "^4.16.3"
+ },
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
"node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
@@ -11551,8 +11523,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash-es": {
"version": "4.17.21",
@@ -11686,7 +11657,6 @@
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
- "optional": true,
"engines": {
"node": ">= 0.4"
}
diff --git a/package.json b/package.json
index cce9fac..6944c9c 100644
--- a/package.json
+++ b/package.json
@@ -155,6 +155,7 @@
"output": "./.reports/eslint-report.xml"
},
"dependencies": {
- "@vojtechlanka/vue-tags-input": "^3.1.1"
+ "@vojtechlanka/vue-tags-input": "^3.1.1",
+ "jsonapi-serializer": "^3.6.9"
}
}
diff --git a/public/assets/images/forum/forum-keyvisual-positive.svg b/public/assets/images/forum/forum-keyvisual-positive.svg
new file mode 100644
index 0000000..2dafbf3
--- /dev/null
+++ b/public/assets/images/forum/forum-keyvisual-positive.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 373 164"><path d="M349.5 96c-10.4 0-19.3 6.7-22.3 15.9L259.8 99c1.5-3.4 2.2-6.8 2.2-10.4s-.8-7.3-2.4-10.7l20.1-4.2c3.9 10.7 14.2 18.4 26.3 18.4s28-12.5 28-28-12.5-28-28-28-28 12.5-28 28 .1 3.2.4 4.7l-21.3 4.5c-3.3-4.9-8.2-9.3-14.5-13.1 3.4-5.9 5.4-12.8 5.4-20.1 0-22.1-17.9-40-40-40s-40 17.9-40 40 .5 7.3 1.5 10.7c-8.5 1.9-16.3 4.9-23 8.8-2 1.1-3.8 2.3-5.5 3.6L97.9 44.5c0-.8.1-1.6.1-2.3 0-12.7-10.5-23-23.5-23S51 29.5 51 42.2s10.5 23 23.5 23 19.2-6.6 22.3-15.7l39.9 17.3c-6.9 6.4-10.6 14-10.6 21.9v.8l-71.6 3.1c-2.6-12.2-13.7-21.4-26.9-21.4S0 83.1 0 98s12.3 27 27.5 27S55 112.9 55 98v-.6l71.7-3.1c1.5 6.5 5.6 12.6 12 17.8l-16.8 7.9c-5.1-8.4-14.3-14-24.8-14-16 0-29 13-29 29s13 29 29 29 29-13 29-29-.7-7.3-2-10.5l19.2-9c1 .7 2.1 1.4 3.2 2 12.7 7.4 29.6 11.5 47.6 11.5s32.7-3.6 45.1-10.1l14.4 8.7c-1.6 3.1-2.5 6.7-2.5 10.4 0 12.7 10.5 23 23.5 23s23.5-10.3 23.5-23-10.5-23-23.5-23-13.9 3.3-18.2 8.4l-12.3-7.4c5.7-3.7 10.2-7.9 13.2-12.5l68.9 13.2c0 .7-.1 1.5-.1 2.3 0 12.7 10.5 23 23.5 23s23.5-10.3 23.5-23-10.5-23-23.5-23ZM194 124c-34.7 0-63-15.9-63-35.5S159.3 53 194 53s63 15.9 63 35.5-28.3 35.5-63 35.5" fill="#e7ebf0"/><path fill="none" d="M182 10h54v54h-54z"/><g fill="#28497c"><path d="M222 21.5h-31c-3 0-5.4 2.4-5.5 5.5v16.4c0 3 2.4 5.4 5.5 5.5h5.3v8.8c5.3 0 10-3.6 11.3-8.8H222c3 0 5.4-2.4 5.5-5.5V27c0-3-2.4-5.4-5.5-5.5m2.5 21.8c0 1.4-1.1 2.5-2.5 2.5h-17c0 3.7-2.3 7-5.8 8.3v-8.3H191c-1.4 0-2.5-1.1-2.5-2.5V26.9c0-1.4 1.1-2.5 2.5-2.5h31c1.4 0 2.5 1.1 2.5 2.5z"/><path d="M227 16.6h-31.3c-2 0-3.8 1.1-4.7 2.9h36.5c1.1.3 1.9 1.3 1.9 2.4v21.4c1.8-.9 2.9-2.7 2.9-4.7V22c0-2.9-2.4-5.3-5.3-5.3Z"/><path d="M192.3 29.3h29.3v3.9h-29.3zm0 6.8h29.3V40h-29.3z"/></g><path fill="none" d="M133 66h49v49h-49z"/><g fill="#28497c"><path d="M165.2 77.7c-2.1 0-3.9 1.7-3.9 3.9s1.7 3.9 3.9 3.9 3.9-1.7 3.9-3.9c0-2.1-1.7-3.9-3.9-3.9m0-3.1c3.8 0 7 3.1 7 7 0 3.8-3.1 7-7 7-3.8 0-7-3.1-7-7 0-3.8 3.1-7 7-7m-5.4 18.5c-1.7 0-3.1 1.4-3.1 3.1v6.1c2.7 1.2 5.7 1.8 8.6 1.6 4.6 0 7.1-1 8.4-1.6v-6.1c0-1.7-1.4-3.1-3.1-3.1zm0-3h10.8c3.4 0 6.2 2.8 6.2 6.2v7.7s-3.1 3.1-11.5 3.1-11.7-3.1-11.7-3.1v-7.7c0-3.4 2.8-6.2 6.2-6.2m-10.8-14c-1.7 0-3.1 1.4-3.1 3.1s1.4 3.1 3.1 3.1 3.1-1.4 3.1-3.1-1.4-3.1-3.1-3.1m0-3c3.4 0 6.2 2.8 6.2 6.2s-2.8 6.2-6.2 6.2-6.2-2.8-6.2-6.2 2.8-6.2 6.2-6.2"/><path d="M144 87.5h10.1c1.2 0 2.3.3 3.2 1-1.7 0-3.3.8-4.4 2.1H144c-1.5 0-2.7 1.2-2.7 2.7v5.5q3.75 1.65 7.8 1.5h2.2v3.1h-2.2c-8 0-10.9-2.9-10.9-2.9v-7.2c0-3.2 2.6-5.8 5.8-5.8"/></g><path fill="none" d="M206 50h50v49h-50z"/><g fill="#28497c"><path d="M223.2 61.6c2.1 0 3.9 1.7 3.9 3.9s-1.7 3.9-3.9 3.9-3.9-1.7-3.9-3.9c0-2.1 1.7-3.9 3.9-3.9m0-3.1c-3.8 0-7 3.1-7 7s3.1 7 7 7 7-3.1 7-7-3.1-7-7-7m5.4 18.6c1.7 0 3.1 1.4 3.1 3.1v6.1c-2.7 1.2-5.7 1.8-8.6 1.6-4.6 0-7.1-1-8.4-1.6v-6.1c0-1.7 1.4-3.1 3.1-3.1zm0-3.1h-10.8c-3.4 0-6.2 2.8-6.2 6.2v7.7s3.1 3.1 11.5 3.1 11.7-3.1 11.7-3.1v-7.7c0-3.4-2.8-6.2-6.2-6.2m10.8-13.9c1.7 0 3.1 1.4 3.1 3.1s-1.4 3.1-3.1 3.1-3.1-1.4-3.1-3.1 1.4-3.1 3.1-3.1m0-3.1c-3.4 0-6.2 2.8-6.2 6.2s2.8 6.2 6.2 6.2 6.2-2.8 6.2-6.2-2.8-6.2-6.2-6.2"/><path d="M244.5 71.5h-10.1c-1.2 0-2.3.3-3.2 1 1.7 0 3.3.8 4.4 2.1h8.9c1.5 0 2.7 1.2 2.7 2.7v5.5q-3.75 1.65-7.8 1.5h-2.2v3.1h2.2c8 0 10.9-2.9 10.9-2.9v-7.2c0-3.2-2.6-5.8-5.8-5.8"/></g><path fill="none" d="M269 131h12v12h-12z"/><path d="M273 134.4h-.7l1.3-1.3v-1.9l-3.7 3.7 3.7 3.7v-1.9l-1.3-1.3h.7c3.4 0 6.2 2.8 6.2 6.2h1.1c0-4-3.3-7.3-7.3-7.3Z" fill="#28497c"/><path fill="none" d="M261 125h27v28h-27z"/><path d="M284.2 128.1h-18.3c-1.5 0-2.8 1.2-2.8 2.8v10.9c0 1.5 1.2 2.8 2.8 2.8h3.7v4.5c2.7 0 5.1-1.9 5.8-4.5h8.9c1.5 0 2.8-1.2 2.8-2.8v-10.9c0-1.5-1.2-2.8-2.8-2.8Zm1.3 13.7c0 .7-.6 1.3-1.3 1.3h-10.1c0 1.9-1.2 3.6-3 4.2v-4.2h-5.2c-.7 0-1.3-.6-1.3-1.3v-10.9c0-.7.6-1.3 1.3-1.3h18.3c.7 0 1.3.6 1.3 1.3z" fill="#28497c"/><path fill="none" d="M286 43h40v40h-40z"/><g fill="#28497c"><path d="M310.3 74.1h-8.1c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h8.1c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1m-.8 3.7h-6.7c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h6.7c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1m-3.3-33.3c-6.9 0-12.6 5.6-12.6 12.6 0 4.4 2.3 8.4 6 10.7.4.3.7.7.7 1.2v2.5c0 .6.5 1.1 1.1 1.1h9.6c.6 0 1.1-.5 1.1-1.1V69c0-.5.2-1 .7-1.2 5.9-3.6 7.8-11.4 4.1-17.3-2.3-3.7-6.3-6-10.7-6m5.4 21.4c-1.1.7-1.7 1.9-1.7 3.1v1.4h-7.4V69c0-1.3-.6-2.5-1.7-3.1-4.9-3-6.4-9.4-3.4-14.2 1.7-2.7 4.5-4.5 7.6-4.9h1.2c5.7 0 10.4 4.6 10.4 10.4 0 3.6-1.9 6.9-4.9 8.8Z"/><path d="M310.2 49a9.4 9.4 0 0 0-11.5 3.2c-.4.6-.3 1.4.3 1.8s1.4.3 1.8-.3c1.3-1.8 3.4-2.9 5.6-2.9 1 0 1.9.2 2.8.6.3.1.7.2 1 0 .7-.3 1-1 .7-1.7-.1-.3-.4-.6-.7-.7m-11.4 5.3c-.7-.2-1.4.2-1.6.9s-.3 1.5-.3 2.2 0 1.3.2 2c.1.6.6 1 1.2 1h.3c.7-.2 1.1-.8 1-1.5-.1-.5-.2-1-.2-1.4 0-.5 0-1.1.2-1.6 0-.3 0-.7-.1-1-.2-.3-.5-.5-.8-.6Z"/></g><path fill="none" d="M79 118h33v34H79z"/><path d="M95.5 143c-2.2 0-4 1.6-4.1 3.8 0 2.2 1.6 4 3.8 4.1h.2c2.2 0 4.1-1.6 4.2-3.8 0-2.2-1.6-4.1-3.8-4.2h-.4Zm.7-22.5c-2.7 0-5.3.6-7.7 1.8l1.6 4.8c1.4-.8 3-1.3 4.6-1.3 2.3 0 3.4 1.1 3.4 2.6s-1.2 2.9-2.7 4.6c-1.8 1.8-2.8 4.3-2.7 6.8v1.1h6.2v-.8c0-2.1.8-4.1 2.4-5.5 1.8-1.8 3.9-4 3.9-7.3s-2.8-6.8-8.9-6.8Z" fill="#28497c"/><path fill="none" d="M177 80h43v43h-43z"/><path d="M198.4 86c2.5 0 4.5 2 4.5 4.5s-2 4.5-4.5 4.5-4.5-2-4.5-4.5 2-4.5 4.5-4.5m0-3.6c-4.5 0-8.1 3.6-8.1 8.1s3.6 8.1 8.1 8.1 8.1-3.6 8.1-8.1-3.6-8.1-8.1-8.1m6.3 21.5c2 0 3.6 1.6 3.6 3.6v7c-1.5.8-4.6 1.9-10 1.9s-8.2-1.1-9.7-1.9v-7c0-2 1.6-3.6 3.6-3.6zm0-3.6h-12.5c-3.9 0-7.2 3.2-7.2 7.1v8.9s3.6 3.6 13.3 3.6 13.6-3.6 13.6-3.6v-8.9c0-3.9-3.2-7.1-7.2-7.1" fill="#28497c"/><path fill="none" d="M165 47h27v27h-27z"/><path d="M178.1 51.1c1.6 0 2.9 1.3 2.9 2.9s-1.3 2.9-2.9 2.9-2.9-1.3-2.9-2.9 1.3-2.9 2.9-2.9m0-2.3c-2.8 0-5.1 2.3-5.1 5.1s2.3 5.1 5.1 5.1 5.1-2.3 5.1-5.1-2.3-5.1-5.1-5.1m4 13.7c1.3 0 2.3 1 2.3 2.3v4.5c-2 .9-4.2 1.3-6.4 1.2-2.1.1-4.3-.3-6.2-1.2v-4.5c0-1.3 1-2.3 2.3-2.3zm0-2.3h-8c-2.5 0-4.6 2-4.6 4.5v5.7s2.3 2.3 8.5 2.3 8.6-2.3 8.6-2.3v-5.7c0-2.5-2.1-4.5-4.6-4.5Z" fill="#28497c"/><path fill="none" d="M55 27h35v33H55z"/><g fill="#28497c"><path d="m73.6 53.6-.6-4.9c2.6-1.5 4.9-3.3 7-5.4 7.2-7.2 6.3-13.7 6.3-13.7s-6.9-.5-13.7 6.3c-2.1 2.1-3.9 4.4-5.4 7l-4.9-.6c-.3 0-.6 0-.8.3l-3.1 3.1c-.4.4-.4 1 0 1.4.1.1.3.2.4.3l5.1 1.5-.6.9 3 3 .9-.6 1.5 5.1c.2.5.7.8 1.2.7.2 0 .3-.1.4-.2l3.1-3.1c.2-.2.3-.5.3-.8Zm-1.9-6.5c-1 .5-2.3.1-2.8-.9-.3-.6-.3-1.3 0-1.9 1.4-2.5 3.2-4.8 5.2-6.8 2.7-2.8 6.2-4.8 10-5.5-.4 2.2-1.6 6-5.6 10-2 2.1-4.3 3.8-6.8 5.2Z"/><circle cx="75.9" cy="40" r="2.1"/><circle cx="79.6" cy="36.3" r="1.6"/><circle cx="72.2" cy="43.7" r="1.6"/><path d="m78.7 50.3-.9 2.4-2.4.9 2.4.9.9 2.4.9-2.4 2.4-.9-2.4-.9zm-14.4-9.9 1.3-3.4 3.4-1.3-3.4-1.3-1.3-3.4-1.3 3.4-3.4 1.3L63 37zm19 9.1.8-2.2 2.2-.8-2.2-.8-.8-2.2-.8 2.2-2.2.8 2.2.8z"/></g><path d="M343.7 111.4c1.7 0 3.8.8 4.5 2.6l.6 1.7c.2.6.9.9 1.5.7.3-.1.5-.4.7-.7l.6-1.7c.7-1.8 2.7-2.6 4.5-2.6 1.3 0 2.6.5 3.6 1.4.9 1.1 1.4 2.5 1.2 4-.1 2.3-2 4.4-5.1 7.3-2.8 2.6-4.7 4.2-5.8 5-1.1-.8-3-2.4-5.8-5-3.1-2.9-5-5-5.1-7.3-.2-1.4.3-2.9 1.2-4 1-.9 2.3-1.4 3.6-1.4Zm12.3-1.8c-2.6 0-5.2 1.3-6.2 3.7-.9-2.4-3.5-3.7-6.2-3.7s-6.9 2.3-6.6 7.3c.2 3 2.5 5.6 5.7 8.5 2.2 2.1 4.6 4.1 7.1 5.9 2.5-1.8 4.8-3.8 7.1-5.9 3.2-3 5.5-5.5 5.7-8.5.3-5-3.2-7.3-6.6-7.3" fill="#28497c"/><path fill="none" d="M12 85h27v27H12z"/><path d="M37.5 90.8H27.3v2.6c0 4.6.7 9.3 2.3 13.6h3.7c-.2-.3-.3-.7-.5-1.1-.6-1.6-1.1-3.2-1.4-4.9h6.1zm-13.6 0H13.7v2.6c0 4.6.7 9.3 2.3 13.6h3.7c-.2-.3-.3-.7-.5-1.1-.6-1.6-1.1-3.2-1.4-4.9h6.1z" fill="#28497c"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/add-reaction.svg b/public/assets/images/icons/black/add-reaction.svg
new file mode 100644
index 0000000..cdbfeaf
--- /dev/null
+++ b/public/assets/images/icons/black/add-reaction.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="signs &amp;amp; feedback" fill="#000000"><path d="M48 4h4v20h-4z"/><path d="M40 12h20v4H40zm-1.89 17.97c2.55-.17 4.45-2.95 4.24-6.18s-2.47-5.73-5.02-5.56-4.45 2.95-4.24 6.18c.21 3.24 2.47 5.74 5.02 5.57Zm-12.81.7c2.55-.17 4.45-2.94 4.24-6.18-.21-3.23-2.47-5.73-5.02-5.56s-4.45 2.94-4.24 6.17c.21 3.24 2.47 5.74 5.02 5.57M32 51.93c-9.93 0-18-6.23-18-13.88 0-.53.21-1.07.59-1.44s.93-.64 1.42-.61l32 .06c1.1 0 2 .9 2 2 0 7.65-8.07 13.88-18 13.88ZM18.3 40c1.34 4.48 7.07 7.93 13.7 7.93s12.35-3.45 13.7-7.88z"/><path d="M53.81 22c1.87 4.07 2.64 8.74 1.92 13.64-1.56 10.51-10.1 18.84-20.64 20.16-15.65 1.97-28.86-11.25-26.9-26.9C9.51 18.36 17.85 9.82 28.35 8.26c4.9-.73 9.57.04 13.64 1.92.75.35 1.64.17 2.23-.41 1-1 .67-2.67-.62-3.26-4.13-1.89-8.8-2.8-13.71-2.44-13.65 1-24.74 12.02-25.8 25.66C2.74 46.97 17.02 61.25 34.26 59.9c13.64-1.07 24.66-12.16 25.66-25.8.36-4.91-.55-9.58-2.44-13.71-.59-1.29-2.26-1.62-3.26-.62-.59.59-.76 1.47-.41 2.23"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/pin.svg b/public/assets/images/icons/black/pin.svg
new file mode 100644
index 0000000..403a300
--- /dev/null
+++ b/public/assets/images/icons/black/pin.svg
@@ -0,0 +1 @@
+<svg fill="#000000" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="Icons – Sammlung"><path d="M48 20c0-8.84-7.16-16-16-16s-16 7.16-16 16c0 7.81 5.6 14.3 13 15.71V57c0 1.66 1.34 3 3 3s3-1.34 3-3V35.71c7.4-1.41 13-7.9 13-15.71M32 32c-6.62 0-12-5.38-12-12S25.38 8 32 8s12 5.38 12 12-5.38 12-12 12"/><circle cx="28" cy="16" r="4"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/quote.svg b/public/assets/images/icons/black/quote.svg
new file mode 100644
index 0000000..1719331
--- /dev/null
+++ b/public/assets/images/icons/black/quote.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M60 14H36.17c-.11 1.97-.17 3.96-.17 6 0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Zm-32 0H4.17C4.06 15.97 4 17.96 4 20c0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Z" fill="#000000"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/quote2.svg b/public/assets/images/icons/black/quote2.svg
new file mode 100644
index 0000000..161cebb
--- /dev/null
+++ b/public/assets/images/icons/black/quote2.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#000000"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M46 17H34.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H46zm-16 0H18.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H30z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/subscription-all.svg b/public/assets/images/icons/black/subscription-all.svg
new file mode 100644
index 0000000..800ecff
--- /dev/null
+++ b/public/assets/images/icons/black/subscription-all.svg
@@ -0,0 +1 @@
+<svg fill="#000000" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M25 58c-2.46 0-4.58-1.84-4.94-4.29L19.81 52H9v-2.23c3.1-.68 5.28-2.96 5.96-6.38 1.01-5.06 2.11-19.62 2.16-20.24.72-6.12 5.3-11.27 11.39-12.72l1.73-.41-.25-2.04c0-1.07.9-1.97 2-1.97s2 .9 2 2l-.03.24-.22 1.77 1.74.42c6.09 1.45 10.67 6.6 11.4 12.81.04.53 1.14 15.1 2.15 20.16.68 3.42 2.86 5.7 5.96 6.38v2.23H30.18l-.25 1.71c-.36 2.45-2.48 4.29-4.94 4.29Z"/><path d="m32 11.65 3.02.72c5.25 1.25 9.21 5.68 9.87 11.02.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22H28.46l-.5 3.42C27.75 54.89 26.47 56 25 56s-2.75-1.11-2.96-2.58l-.5-3.42h-8.33c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39.66-5.34 4.62-9.76 9.87-11.02zM32 4c-2.21 0-4 1.79-4 4 0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57v-6.01c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/subscription-end.svg b/public/assets/images/icons/black/subscription-end.svg
new file mode 100644
index 0000000..57e237f
--- /dev/null
+++ b/public/assets/images/icons/black/subscription-end.svg
@@ -0,0 +1 @@
+<svg fill="#000000" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="m43.33 38.67-6.65-6.64 6.65-6.65-4.68-4.68L32 27.34l-6.65-6.64-4.67 4.68 6.64 6.64-6.65 6.65 4.69 4.68 6.65-6.65 6.64 6.65z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/subscription-none.svg b/public/assets/images/icons/black/subscription-none.svg
new file mode 100644
index 0000000..a5260aa
--- /dev/null
+++ b/public/assets/images/icons/black/subscription-none.svg
@@ -0,0 +1 @@
+<svg fill="#000000" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/black/subscription-quotes.svg b/public/assets/images/icons/black/subscription-quotes.svg
new file mode 100644
index 0000000..61add1b
--- /dev/null
+++ b/public/assets/images/icons/black/subscription-quotes.svg
@@ -0,0 +1 @@
+<svg fill="#000000" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M43 27.07h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51H43zm-12.57 0h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51h5.63z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/add-reaction.svg b/public/assets/images/icons/blue/add-reaction.svg
new file mode 100644
index 0000000..75997f3
--- /dev/null
+++ b/public/assets/images/icons/blue/add-reaction.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="signs &amp;amp; feedback" fill="#28497c"><path d="M48 4h4v20h-4z"/><path d="M40 12h20v4H40zm-1.89 17.97c2.55-.17 4.45-2.95 4.24-6.18s-2.47-5.73-5.02-5.56-4.45 2.95-4.24 6.18c.21 3.24 2.47 5.74 5.02 5.57Zm-12.81.7c2.55-.17 4.45-2.94 4.24-6.18-.21-3.23-2.47-5.73-5.02-5.56s-4.45 2.94-4.24 6.17c.21 3.24 2.47 5.74 5.02 5.57M32 51.93c-9.93 0-18-6.23-18-13.88 0-.53.21-1.07.59-1.44s.93-.64 1.42-.61l32 .06c1.1 0 2 .9 2 2 0 7.65-8.07 13.88-18 13.88ZM18.3 40c1.34 4.48 7.07 7.93 13.7 7.93s12.35-3.45 13.7-7.88z"/><path d="M53.81 22c1.87 4.07 2.64 8.74 1.92 13.64-1.56 10.51-10.1 18.84-20.64 20.16-15.65 1.97-28.86-11.25-26.9-26.9C9.51 18.36 17.85 9.82 28.35 8.26c4.9-.73 9.57.04 13.64 1.92.75.35 1.64.17 2.23-.41 1-1 .67-2.67-.62-3.26-4.13-1.89-8.8-2.8-13.71-2.44-13.65 1-24.74 12.02-25.8 25.66C2.74 46.97 17.02 61.25 34.26 59.9c13.64-1.07 24.66-12.16 25.66-25.8.36-4.91-.55-9.58-2.44-13.71-.59-1.29-2.26-1.62-3.26-.62-.59.59-.76 1.47-.41 2.23"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/pin.svg b/public/assets/images/icons/blue/pin.svg
new file mode 100644
index 0000000..958cd0f
--- /dev/null
+++ b/public/assets/images/icons/blue/pin.svg
@@ -0,0 +1 @@
+<svg fill="#28497c" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="Icons – Sammlung"><path d="M48 20c0-8.84-7.16-16-16-16s-16 7.16-16 16c0 7.81 5.6 14.3 13 15.71V57c0 1.66 1.34 3 3 3s3-1.34 3-3V35.71c7.4-1.41 13-7.9 13-15.71M32 32c-6.62 0-12-5.38-12-12S25.38 8 32 8s12 5.38 12 12-5.38 12-12 12"/><circle cx="28" cy="16" r="4"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/quote.svg b/public/assets/images/icons/blue/quote.svg
new file mode 100644
index 0000000..f4661e5
--- /dev/null
+++ b/public/assets/images/icons/blue/quote.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M60 14H36.17c-.11 1.97-.17 3.96-.17 6 0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Zm-32 0H4.17C4.06 15.97 4 17.96 4 20c0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Z" fill="#28497c"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/quote2.svg b/public/assets/images/icons/blue/quote2.svg
new file mode 100644
index 0000000..97b7272
--- /dev/null
+++ b/public/assets/images/icons/blue/quote2.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#28497c"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M46 17H34.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H46zm-16 0H18.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H30z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/subscription-all.svg b/public/assets/images/icons/blue/subscription-all.svg
new file mode 100644
index 0000000..a9bdc0c
--- /dev/null
+++ b/public/assets/images/icons/blue/subscription-all.svg
@@ -0,0 +1 @@
+<svg fill="#28497c" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M25 58c-2.46 0-4.58-1.84-4.94-4.29L19.81 52H9v-2.23c3.1-.68 5.28-2.96 5.96-6.38 1.01-5.06 2.11-19.62 2.16-20.24.72-6.12 5.3-11.27 11.39-12.72l1.73-.41-.25-2.04c0-1.07.9-1.97 2-1.97s2 .9 2 2l-.03.24-.22 1.77 1.74.42c6.09 1.45 10.67 6.6 11.4 12.81.04.53 1.14 15.1 2.15 20.16.68 3.42 2.86 5.7 5.96 6.38v2.23H30.18l-.25 1.71c-.36 2.45-2.48 4.29-4.94 4.29Z"/><path d="m32 11.65 3.02.72c5.25 1.25 9.21 5.68 9.87 11.02.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22H28.46l-.5 3.42C27.75 54.89 26.47 56 25 56s-2.75-1.11-2.96-2.58l-.5-3.42h-8.33c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39.66-5.34 4.62-9.76 9.87-11.02zM32 4c-2.21 0-4 1.79-4 4 0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57v-6.01c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/subscription-end.svg b/public/assets/images/icons/blue/subscription-end.svg
new file mode 100644
index 0000000..d9f6583
--- /dev/null
+++ b/public/assets/images/icons/blue/subscription-end.svg
@@ -0,0 +1 @@
+<svg fill="#28497c" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="m43.33 38.67-6.65-6.64 6.65-6.65-4.68-4.68L32 27.34l-6.65-6.64-4.67 4.68 6.64 6.64-6.65 6.65 4.69 4.68 6.65-6.65 6.64 6.65z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/subscription-none.svg b/public/assets/images/icons/blue/subscription-none.svg
new file mode 100644
index 0000000..8b2f6ad
--- /dev/null
+++ b/public/assets/images/icons/blue/subscription-none.svg
@@ -0,0 +1 @@
+<svg fill="#28497c" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/blue/subscription-quotes.svg b/public/assets/images/icons/blue/subscription-quotes.svg
new file mode 100644
index 0000000..477b75e
--- /dev/null
+++ b/public/assets/images/icons/blue/subscription-quotes.svg
@@ -0,0 +1 @@
+<svg fill="#28497c" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M43 27.07h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51H43zm-12.57 0h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51h5.63z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/add-reaction.svg b/public/assets/images/icons/green/add-reaction.svg
new file mode 100644
index 0000000..daf0741
--- /dev/null
+++ b/public/assets/images/icons/green/add-reaction.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="signs &amp;amp; feedback" fill="#00962d"><path d="M48 4h4v20h-4z"/><path d="M40 12h20v4H40zm-1.89 17.97c2.55-.17 4.45-2.95 4.24-6.18s-2.47-5.73-5.02-5.56-4.45 2.95-4.24 6.18c.21 3.24 2.47 5.74 5.02 5.57Zm-12.81.7c2.55-.17 4.45-2.94 4.24-6.18-.21-3.23-2.47-5.73-5.02-5.56s-4.45 2.94-4.24 6.17c.21 3.24 2.47 5.74 5.02 5.57M32 51.93c-9.93 0-18-6.23-18-13.88 0-.53.21-1.07.59-1.44s.93-.64 1.42-.61l32 .06c1.1 0 2 .9 2 2 0 7.65-8.07 13.88-18 13.88ZM18.3 40c1.34 4.48 7.07 7.93 13.7 7.93s12.35-3.45 13.7-7.88z"/><path d="M53.81 22c1.87 4.07 2.64 8.74 1.92 13.64-1.56 10.51-10.1 18.84-20.64 20.16-15.65 1.97-28.86-11.25-26.9-26.9C9.51 18.36 17.85 9.82 28.35 8.26c4.9-.73 9.57.04 13.64 1.92.75.35 1.64.17 2.23-.41 1-1 .67-2.67-.62-3.26-4.13-1.89-8.8-2.8-13.71-2.44-13.65 1-24.74 12.02-25.8 25.66C2.74 46.97 17.02 61.25 34.26 59.9c13.64-1.07 24.66-12.16 25.66-25.8.36-4.91-.55-9.58-2.44-13.71-.59-1.29-2.26-1.62-3.26-.62-.59.59-.76 1.47-.41 2.23"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/pin.svg b/public/assets/images/icons/green/pin.svg
new file mode 100644
index 0000000..0510655
--- /dev/null
+++ b/public/assets/images/icons/green/pin.svg
@@ -0,0 +1 @@
+<svg fill="#00962d" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="Icons – Sammlung"><path d="M48 20c0-8.84-7.16-16-16-16s-16 7.16-16 16c0 7.81 5.6 14.3 13 15.71V57c0 1.66 1.34 3 3 3s3-1.34 3-3V35.71c7.4-1.41 13-7.9 13-15.71M32 32c-6.62 0-12-5.38-12-12S25.38 8 32 8s12 5.38 12 12-5.38 12-12 12"/><circle cx="28" cy="16" r="4"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/quote.svg b/public/assets/images/icons/green/quote.svg
new file mode 100644
index 0000000..0c94106
--- /dev/null
+++ b/public/assets/images/icons/green/quote.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M60 14H36.17c-.11 1.97-.17 3.96-.17 6 0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Zm-32 0H4.17C4.06 15.97 4 17.96 4 20c0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Z" fill="#00962d"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/quote2.svg b/public/assets/images/icons/green/quote2.svg
new file mode 100644
index 0000000..ea1095e
--- /dev/null
+++ b/public/assets/images/icons/green/quote2.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#00962d"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M46 17H34.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H46zm-16 0H18.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H30z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/subscription-all.svg b/public/assets/images/icons/green/subscription-all.svg
new file mode 100644
index 0000000..40f3e4d
--- /dev/null
+++ b/public/assets/images/icons/green/subscription-all.svg
@@ -0,0 +1 @@
+<svg fill="#00962d" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M25 58c-2.46 0-4.58-1.84-4.94-4.29L19.81 52H9v-2.23c3.1-.68 5.28-2.96 5.96-6.38 1.01-5.06 2.11-19.62 2.16-20.24.72-6.12 5.3-11.27 11.39-12.72l1.73-.41-.25-2.04c0-1.07.9-1.97 2-1.97s2 .9 2 2l-.03.24-.22 1.77 1.74.42c6.09 1.45 10.67 6.6 11.4 12.81.04.53 1.14 15.1 2.15 20.16.68 3.42 2.86 5.7 5.96 6.38v2.23H30.18l-.25 1.71c-.36 2.45-2.48 4.29-4.94 4.29Z"/><path d="m32 11.65 3.02.72c5.25 1.25 9.21 5.68 9.87 11.02.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22H28.46l-.5 3.42C27.75 54.89 26.47 56 25 56s-2.75-1.11-2.96-2.58l-.5-3.42h-8.33c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39.66-5.34 4.62-9.76 9.87-11.02zM32 4c-2.21 0-4 1.79-4 4 0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57v-6.01c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/subscription-end.svg b/public/assets/images/icons/green/subscription-end.svg
new file mode 100644
index 0000000..decbb13
--- /dev/null
+++ b/public/assets/images/icons/green/subscription-end.svg
@@ -0,0 +1 @@
+<svg fill="#00962d" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="m43.33 38.67-6.65-6.64 6.65-6.65-4.68-4.68L32 27.34l-6.65-6.64-4.67 4.68 6.64 6.64-6.65 6.65 4.69 4.68 6.65-6.65 6.64 6.65z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/subscription-none.svg b/public/assets/images/icons/green/subscription-none.svg
new file mode 100644
index 0000000..44aa356
--- /dev/null
+++ b/public/assets/images/icons/green/subscription-none.svg
@@ -0,0 +1 @@
+<svg fill="#00962d" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/green/subscription-quotes.svg b/public/assets/images/icons/green/subscription-quotes.svg
new file mode 100644
index 0000000..414391d
--- /dev/null
+++ b/public/assets/images/icons/green/subscription-quotes.svg
@@ -0,0 +1 @@
+<svg fill="#00962d" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M43 27.07h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51H43zm-12.57 0h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51h5.63z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/add-reaction.svg b/public/assets/images/icons/grey/add-reaction.svg
new file mode 100644
index 0000000..4d8fde0
--- /dev/null
+++ b/public/assets/images/icons/grey/add-reaction.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="signs &amp;amp; feedback" fill="#6e6e6e"><path d="M48 4h4v20h-4z"/><path d="M40 12h20v4H40zm-1.89 17.97c2.55-.17 4.45-2.95 4.24-6.18s-2.47-5.73-5.02-5.56-4.45 2.95-4.24 6.18c.21 3.24 2.47 5.74 5.02 5.57Zm-12.81.7c2.55-.17 4.45-2.94 4.24-6.18-.21-3.23-2.47-5.73-5.02-5.56s-4.45 2.94-4.24 6.17c.21 3.24 2.47 5.74 5.02 5.57M32 51.93c-9.93 0-18-6.23-18-13.88 0-.53.21-1.07.59-1.44s.93-.64 1.42-.61l32 .06c1.1 0 2 .9 2 2 0 7.65-8.07 13.88-18 13.88ZM18.3 40c1.34 4.48 7.07 7.93 13.7 7.93s12.35-3.45 13.7-7.88z"/><path d="M53.81 22c1.87 4.07 2.64 8.74 1.92 13.64-1.56 10.51-10.1 18.84-20.64 20.16-15.65 1.97-28.86-11.25-26.9-26.9C9.51 18.36 17.85 9.82 28.35 8.26c4.9-.73 9.57.04 13.64 1.92.75.35 1.64.17 2.23-.41 1-1 .67-2.67-.62-3.26-4.13-1.89-8.8-2.8-13.71-2.44-13.65 1-24.74 12.02-25.8 25.66C2.74 46.97 17.02 61.25 34.26 59.9c13.64-1.07 24.66-12.16 25.66-25.8.36-4.91-.55-9.58-2.44-13.71-.59-1.29-2.26-1.62-3.26-.62-.59.59-.76 1.47-.41 2.23"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/pin.svg b/public/assets/images/icons/grey/pin.svg
new file mode 100644
index 0000000..b987ec9
--- /dev/null
+++ b/public/assets/images/icons/grey/pin.svg
@@ -0,0 +1 @@
+<svg fill="#6e6e6e" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="Icons – Sammlung"><path d="M48 20c0-8.84-7.16-16-16-16s-16 7.16-16 16c0 7.81 5.6 14.3 13 15.71V57c0 1.66 1.34 3 3 3s3-1.34 3-3V35.71c7.4-1.41 13-7.9 13-15.71M32 32c-6.62 0-12-5.38-12-12S25.38 8 32 8s12 5.38 12 12-5.38 12-12 12"/><circle cx="28" cy="16" r="4"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/quote.svg b/public/assets/images/icons/grey/quote.svg
new file mode 100644
index 0000000..400ac85
--- /dev/null
+++ b/public/assets/images/icons/grey/quote.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M60 14H36.17c-.11 1.97-.17 3.96-.17 6 0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Zm-32 0H4.17C4.06 15.97 4 17.96 4 20c0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Z" fill="#6e6e6e"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/quote2.svg b/public/assets/images/icons/grey/quote2.svg
new file mode 100644
index 0000000..41d576c
--- /dev/null
+++ b/public/assets/images/icons/grey/quote2.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#6e6e6e"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M46 17H34.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H46zm-16 0H18.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H30z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/subscription-all.svg b/public/assets/images/icons/grey/subscription-all.svg
new file mode 100644
index 0000000..777b726
--- /dev/null
+++ b/public/assets/images/icons/grey/subscription-all.svg
@@ -0,0 +1 @@
+<svg fill="#6e6e6e" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M25 58c-2.46 0-4.58-1.84-4.94-4.29L19.81 52H9v-2.23c3.1-.68 5.28-2.96 5.96-6.38 1.01-5.06 2.11-19.62 2.16-20.24.72-6.12 5.3-11.27 11.39-12.72l1.73-.41-.25-2.04c0-1.07.9-1.97 2-1.97s2 .9 2 2l-.03.24-.22 1.77 1.74.42c6.09 1.45 10.67 6.6 11.4 12.81.04.53 1.14 15.1 2.15 20.16.68 3.42 2.86 5.7 5.96 6.38v2.23H30.18l-.25 1.71c-.36 2.45-2.48 4.29-4.94 4.29Z"/><path d="m32 11.65 3.02.72c5.25 1.25 9.21 5.68 9.87 11.02.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22H28.46l-.5 3.42C27.75 54.89 26.47 56 25 56s-2.75-1.11-2.96-2.58l-.5-3.42h-8.33c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39.66-5.34 4.62-9.76 9.87-11.02zM32 4c-2.21 0-4 1.79-4 4 0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57v-6.01c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/subscription-end.svg b/public/assets/images/icons/grey/subscription-end.svg
new file mode 100644
index 0000000..88deffc
--- /dev/null
+++ b/public/assets/images/icons/grey/subscription-end.svg
@@ -0,0 +1 @@
+<svg fill="#6e6e6e" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="m43.33 38.67-6.65-6.64 6.65-6.65-4.68-4.68L32 27.34l-6.65-6.64-4.67 4.68 6.64 6.64-6.65 6.65 4.69 4.68 6.65-6.65 6.64 6.65z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/subscription-none.svg b/public/assets/images/icons/grey/subscription-none.svg
new file mode 100644
index 0000000..9330871
--- /dev/null
+++ b/public/assets/images/icons/grey/subscription-none.svg
@@ -0,0 +1 @@
+<svg fill="#6e6e6e" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/grey/subscription-quotes.svg b/public/assets/images/icons/grey/subscription-quotes.svg
new file mode 100644
index 0000000..ec7200f
--- /dev/null
+++ b/public/assets/images/icons/grey/subscription-quotes.svg
@@ -0,0 +1 @@
+<svg fill="#6e6e6e" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M43 27.07h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51H43zm-12.57 0h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51h5.63z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/add-reaction.svg b/public/assets/images/icons/red/add-reaction.svg
new file mode 100644
index 0000000..ce083e4
--- /dev/null
+++ b/public/assets/images/icons/red/add-reaction.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="signs &amp;amp; feedback" fill="#cb1800"><path d="M48 4h4v20h-4z"/><path d="M40 12h20v4H40zm-1.89 17.97c2.55-.17 4.45-2.95 4.24-6.18s-2.47-5.73-5.02-5.56-4.45 2.95-4.24 6.18c.21 3.24 2.47 5.74 5.02 5.57Zm-12.81.7c2.55-.17 4.45-2.94 4.24-6.18-.21-3.23-2.47-5.73-5.02-5.56s-4.45 2.94-4.24 6.17c.21 3.24 2.47 5.74 5.02 5.57M32 51.93c-9.93 0-18-6.23-18-13.88 0-.53.21-1.07.59-1.44s.93-.64 1.42-.61l32 .06c1.1 0 2 .9 2 2 0 7.65-8.07 13.88-18 13.88ZM18.3 40c1.34 4.48 7.07 7.93 13.7 7.93s12.35-3.45 13.7-7.88z"/><path d="M53.81 22c1.87 4.07 2.64 8.74 1.92 13.64-1.56 10.51-10.1 18.84-20.64 20.16-15.65 1.97-28.86-11.25-26.9-26.9C9.51 18.36 17.85 9.82 28.35 8.26c4.9-.73 9.57.04 13.64 1.92.75.35 1.64.17 2.23-.41 1-1 .67-2.67-.62-3.26-4.13-1.89-8.8-2.8-13.71-2.44-13.65 1-24.74 12.02-25.8 25.66C2.74 46.97 17.02 61.25 34.26 59.9c13.64-1.07 24.66-12.16 25.66-25.8.36-4.91-.55-9.58-2.44-13.71-.59-1.29-2.26-1.62-3.26-.62-.59.59-.76 1.47-.41 2.23"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/pin.svg b/public/assets/images/icons/red/pin.svg
new file mode 100644
index 0000000..b1e869d
--- /dev/null
+++ b/public/assets/images/icons/red/pin.svg
@@ -0,0 +1 @@
+<svg fill="#cb1800" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="Icons – Sammlung"><path d="M48 20c0-8.84-7.16-16-16-16s-16 7.16-16 16c0 7.81 5.6 14.3 13 15.71V57c0 1.66 1.34 3 3 3s3-1.34 3-3V35.71c7.4-1.41 13-7.9 13-15.71M32 32c-6.62 0-12-5.38-12-12S25.38 8 32 8s12 5.38 12 12-5.38 12-12 12"/><circle cx="28" cy="16" r="4"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/quote.svg b/public/assets/images/icons/red/quote.svg
new file mode 100644
index 0000000..3191c5a
--- /dev/null
+++ b/public/assets/images/icons/red/quote.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M60 14H36.17c-.11 1.97-.17 3.96-.17 6 0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Zm-32 0H4.17C4.06 15.97 4 17.96 4 20c0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Z" fill="#cb1800"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/quote2.svg b/public/assets/images/icons/red/quote2.svg
new file mode 100644
index 0000000..218d4b6
--- /dev/null
+++ b/public/assets/images/icons/red/quote2.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#cb1800"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M46 17H34.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H46zm-16 0H18.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H30z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/subscription-all.svg b/public/assets/images/icons/red/subscription-all.svg
new file mode 100644
index 0000000..bd57981
--- /dev/null
+++ b/public/assets/images/icons/red/subscription-all.svg
@@ -0,0 +1 @@
+<svg fill="#cb1800" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M25 58c-2.46 0-4.58-1.84-4.94-4.29L19.81 52H9v-2.23c3.1-.68 5.28-2.96 5.96-6.38 1.01-5.06 2.11-19.62 2.16-20.24.72-6.12 5.3-11.27 11.39-12.72l1.73-.41-.25-2.04c0-1.07.9-1.97 2-1.97s2 .9 2 2l-.03.24-.22 1.77 1.74.42c6.09 1.45 10.67 6.6 11.4 12.81.04.53 1.14 15.1 2.15 20.16.68 3.42 2.86 5.7 5.96 6.38v2.23H30.18l-.25 1.71c-.36 2.45-2.48 4.29-4.94 4.29Z"/><path d="m32 11.65 3.02.72c5.25 1.25 9.21 5.68 9.87 11.02.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22H28.46l-.5 3.42C27.75 54.89 26.47 56 25 56s-2.75-1.11-2.96-2.58l-.5-3.42h-8.33c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39.66-5.34 4.62-9.76 9.87-11.02zM32 4c-2.21 0-4 1.79-4 4 0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57v-6.01c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/subscription-end.svg b/public/assets/images/icons/red/subscription-end.svg
new file mode 100644
index 0000000..fe68849
--- /dev/null
+++ b/public/assets/images/icons/red/subscription-end.svg
@@ -0,0 +1 @@
+<svg fill="#cb1800" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="m43.33 38.67-6.65-6.64 6.65-6.65-4.68-4.68L32 27.34l-6.65-6.64-4.67 4.68 6.64 6.64-6.65 6.65 4.69 4.68 6.65-6.65 6.64 6.65z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/subscription-none.svg b/public/assets/images/icons/red/subscription-none.svg
new file mode 100644
index 0000000..e4e136d
--- /dev/null
+++ b/public/assets/images/icons/red/subscription-none.svg
@@ -0,0 +1 @@
+<svg fill="#cb1800" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/red/subscription-quotes.svg b/public/assets/images/icons/red/subscription-quotes.svg
new file mode 100644
index 0000000..d724b86
--- /dev/null
+++ b/public/assets/images/icons/red/subscription-quotes.svg
@@ -0,0 +1 @@
+<svg fill="#cb1800" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M43 27.07h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51H43zm-12.57 0h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51h5.63z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/add-reaction.svg b/public/assets/images/icons/white/add-reaction.svg
new file mode 100644
index 0000000..4b378cd
--- /dev/null
+++ b/public/assets/images/icons/white/add-reaction.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="signs &amp;amp; feedback" fill="#ffffff"><path d="M48 4h4v20h-4z"/><path d="M40 12h20v4H40zm-1.89 17.97c2.55-.17 4.45-2.95 4.24-6.18s-2.47-5.73-5.02-5.56-4.45 2.95-4.24 6.18c.21 3.24 2.47 5.74 5.02 5.57Zm-12.81.7c2.55-.17 4.45-2.94 4.24-6.18-.21-3.23-2.47-5.73-5.02-5.56s-4.45 2.94-4.24 6.17c.21 3.24 2.47 5.74 5.02 5.57M32 51.93c-9.93 0-18-6.23-18-13.88 0-.53.21-1.07.59-1.44s.93-.64 1.42-.61l32 .06c1.1 0 2 .9 2 2 0 7.65-8.07 13.88-18 13.88ZM18.3 40c1.34 4.48 7.07 7.93 13.7 7.93s12.35-3.45 13.7-7.88z"/><path d="M53.81 22c1.87 4.07 2.64 8.74 1.92 13.64-1.56 10.51-10.1 18.84-20.64 20.16-15.65 1.97-28.86-11.25-26.9-26.9C9.51 18.36 17.85 9.82 28.35 8.26c4.9-.73 9.57.04 13.64 1.92.75.35 1.64.17 2.23-.41 1-1 .67-2.67-.62-3.26-4.13-1.89-8.8-2.8-13.71-2.44-13.65 1-24.74 12.02-25.8 25.66C2.74 46.97 17.02 61.25 34.26 59.9c13.64-1.07 24.66-12.16 25.66-25.8.36-4.91-.55-9.58-2.44-13.71-.59-1.29-2.26-1.62-3.26-.62-.59.59-.76 1.47-.41 2.23"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/pin.svg b/public/assets/images/icons/white/pin.svg
new file mode 100644
index 0000000..5ab8bec
--- /dev/null
+++ b/public/assets/images/icons/white/pin.svg
@@ -0,0 +1 @@
+<svg fill="#ffffff" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="Icons – Sammlung"><path d="M48 20c0-8.84-7.16-16-16-16s-16 7.16-16 16c0 7.81 5.6 14.3 13 15.71V57c0 1.66 1.34 3 3 3s3-1.34 3-3V35.71c7.4-1.41 13-7.9 13-15.71M32 32c-6.62 0-12-5.38-12-12S25.38 8 32 8s12 5.38 12 12-5.38 12-12 12"/><circle cx="28" cy="16" r="4"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/quote.svg b/public/assets/images/icons/white/quote.svg
new file mode 100644
index 0000000..f188165
--- /dev/null
+++ b/public/assets/images/icons/white/quote.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M60 14H36.17c-.11 1.97-.17 3.96-.17 6 0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Zm-32 0H4.17C4.06 15.97 4 17.96 4 20c0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Z" fill="#ffffff"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/quote2.svg b/public/assets/images/icons/white/quote2.svg
new file mode 100644
index 0000000..debca48
--- /dev/null
+++ b/public/assets/images/icons/white/quote2.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#ffffff"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M46 17H34.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H46zm-16 0H18.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H30z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/subscription-all.svg b/public/assets/images/icons/white/subscription-all.svg
new file mode 100644
index 0000000..73fccbd
--- /dev/null
+++ b/public/assets/images/icons/white/subscription-all.svg
@@ -0,0 +1 @@
+<svg fill="#ffffff" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M25 58c-2.46 0-4.58-1.84-4.94-4.29L19.81 52H9v-2.23c3.1-.68 5.28-2.96 5.96-6.38 1.01-5.06 2.11-19.62 2.16-20.24.72-6.12 5.3-11.27 11.39-12.72l1.73-.41-.25-2.04c0-1.07.9-1.97 2-1.97s2 .9 2 2l-.03.24-.22 1.77 1.74.42c6.09 1.45 10.67 6.6 11.4 12.81.04.53 1.14 15.1 2.15 20.16.68 3.42 2.86 5.7 5.96 6.38v2.23H30.18l-.25 1.71c-.36 2.45-2.48 4.29-4.94 4.29Z"/><path d="m32 11.65 3.02.72c5.25 1.25 9.21 5.68 9.87 11.02.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22H28.46l-.5 3.42C27.75 54.89 26.47 56 25 56s-2.75-1.11-2.96-2.58l-.5-3.42h-8.33c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39.66-5.34 4.62-9.76 9.87-11.02zM32 4c-2.21 0-4 1.79-4 4 0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57v-6.01c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/subscription-end.svg b/public/assets/images/icons/white/subscription-end.svg
new file mode 100644
index 0000000..8444a64
--- /dev/null
+++ b/public/assets/images/icons/white/subscription-end.svg
@@ -0,0 +1 @@
+<svg fill="#ffffff" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="m43.33 38.67-6.65-6.64 6.65-6.65-4.68-4.68L32 27.34l-6.65-6.64-4.67 4.68 6.64 6.64-6.65 6.65 4.69 4.68 6.65-6.65 6.64 6.65z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/subscription-none.svg b/public/assets/images/icons/white/subscription-none.svg
new file mode 100644
index 0000000..8b704dd
--- /dev/null
+++ b/public/assets/images/icons/white/subscription-none.svg
@@ -0,0 +1 @@
+<svg fill="#ffffff" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/white/subscription-quotes.svg b/public/assets/images/icons/white/subscription-quotes.svg
new file mode 100644
index 0000000..2f2fecc
--- /dev/null
+++ b/public/assets/images/icons/white/subscription-quotes.svg
@@ -0,0 +1 @@
+<svg fill="#ffffff" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M43 27.07h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51H43zm-12.57 0h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51h5.63z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/add-reaction.svg b/public/assets/images/icons/yellow/add-reaction.svg
new file mode 100644
index 0000000..33904d2
--- /dev/null
+++ b/public/assets/images/icons/yellow/add-reaction.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="signs &amp;amp; feedback" fill="#ffad00"><path d="M48 4h4v20h-4z"/><path d="M40 12h20v4H40zm-1.89 17.97c2.55-.17 4.45-2.95 4.24-6.18s-2.47-5.73-5.02-5.56-4.45 2.95-4.24 6.18c.21 3.24 2.47 5.74 5.02 5.57Zm-12.81.7c2.55-.17 4.45-2.94 4.24-6.18-.21-3.23-2.47-5.73-5.02-5.56s-4.45 2.94-4.24 6.17c.21 3.24 2.47 5.74 5.02 5.57M32 51.93c-9.93 0-18-6.23-18-13.88 0-.53.21-1.07.59-1.44s.93-.64 1.42-.61l32 .06c1.1 0 2 .9 2 2 0 7.65-8.07 13.88-18 13.88ZM18.3 40c1.34 4.48 7.07 7.93 13.7 7.93s12.35-3.45 13.7-7.88z"/><path d="M53.81 22c1.87 4.07 2.64 8.74 1.92 13.64-1.56 10.51-10.1 18.84-20.64 20.16-15.65 1.97-28.86-11.25-26.9-26.9C9.51 18.36 17.85 9.82 28.35 8.26c4.9-.73 9.57.04 13.64 1.92.75.35 1.64.17 2.23-.41 1-1 .67-2.67-.62-3.26-4.13-1.89-8.8-2.8-13.71-2.44-13.65 1-24.74 12.02-25.8 25.66C2.74 46.97 17.02 61.25 34.26 59.9c13.64-1.07 24.66-12.16 25.66-25.8.36-4.91-.55-9.58-2.44-13.71-.59-1.29-2.26-1.62-3.26-.62-.59.59-.76 1.47-.41 2.23"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/pin.svg b/public/assets/images/icons/yellow/pin.svg
new file mode 100644
index 0000000..ef2a97f
--- /dev/null
+++ b/public/assets/images/icons/yellow/pin.svg
@@ -0,0 +1 @@
+<svg fill="#ffad00" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g data-name="Icons – Sammlung"><path d="M48 20c0-8.84-7.16-16-16-16s-16 7.16-16 16c0 7.81 5.6 14.3 13 15.71V57c0 1.66 1.34 3 3 3s3-1.34 3-3V35.71c7.4-1.41 13-7.9 13-15.71M32 32c-6.62 0-12-5.38-12-12S25.38 8 32 8s12 5.38 12 12-5.38 12-12 12"/><circle cx="28" cy="16" r="4"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/quote.svg b/public/assets/images/icons/yellow/quote.svg
new file mode 100644
index 0000000..bc16425
--- /dev/null
+++ b/public/assets/images/icons/yellow/quote.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M60 14H36.17c-.11 1.97-.17 3.96-.17 6 0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Zm-32 0H4.17C4.06 15.97 4 17.96 4 20c0 12.44 2.04 23.72 5.36 32h8.76c-.38-.75-.77-1.59-1.15-2.53-1.37-3.31-2.46-7.22-3.28-11.47h14.33V14Z" fill="#ffad00"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/quote2.svg b/public/assets/images/icons/yellow/quote2.svg
new file mode 100644
index 0000000..ca6f4bc
--- /dev/null
+++ b/public/assets/images/icons/yellow/quote2.svg
@@ -0,0 +1 @@
+<svg data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><g fill="#ffad00"><path d="M53.48 7.54H10.53c-3.59 0-6.52 2.94-6.52 6.52v25.45c0 3.59 2.94 6.52 6.52 6.52h8.65l.03 10.5c6.46-.02 11.93-4.5 13.5-10.5h20.78c3.59 0 6.52-2.94 6.52-6.52V14.06c0-3.59-2.94-6.52-6.52-6.52Zm3.02 31.98c0 1.67-1.36 3.02-3.02 3.02H29.67c0 4.55-2.92 8.44-6.98 9.89l-.02-9.89H10.53c-1.67 0-3.02-1.36-3.02-3.02V14.06c0-1.67 1.36-3.02 3.02-3.02h42.95c1.67 0 3.02 1.36 3.02 3.02v25.45Z"/><path d="M46 17H34.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H46zm-16 0H18.08c-.05.98-.08 1.98-.08 3 0 6.22 1.02 11.86 2.68 16h4.38c-.19-.38-.38-.79-.58-1.26-.68-1.65-1.23-3.61-1.64-5.74H30z"/></g></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/subscription-all.svg b/public/assets/images/icons/yellow/subscription-all.svg
new file mode 100644
index 0000000..fda754d
--- /dev/null
+++ b/public/assets/images/icons/yellow/subscription-all.svg
@@ -0,0 +1 @@
+<svg fill="#ffad00" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M25 58c-2.46 0-4.58-1.84-4.94-4.29L19.81 52H9v-2.23c3.1-.68 5.28-2.96 5.96-6.38 1.01-5.06 2.11-19.62 2.16-20.24.72-6.12 5.3-11.27 11.39-12.72l1.73-.41-.25-2.04c0-1.07.9-1.97 2-1.97s2 .9 2 2l-.03.24-.22 1.77 1.74.42c6.09 1.45 10.67 6.6 11.4 12.81.04.53 1.14 15.1 2.15 20.16.68 3.42 2.86 5.7 5.96 6.38v2.23H30.18l-.25 1.71c-.36 2.45-2.48 4.29-4.94 4.29Z"/><path d="m32 11.65 3.02.72c5.25 1.25 9.21 5.68 9.87 11.02.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22H28.46l-.5 3.42C27.75 54.89 26.47 56 25 56s-2.75-1.11-2.96-2.58l-.5-3.42h-8.33c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39.66-5.34 4.62-9.76 9.87-11.02zM32 4c-2.21 0-4 1.79-4 4 0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57v-6.01c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/subscription-end.svg b/public/assets/images/icons/yellow/subscription-end.svg
new file mode 100644
index 0000000..bd5f165
--- /dev/null
+++ b/public/assets/images/icons/yellow/subscription-end.svg
@@ -0,0 +1 @@
+<svg fill="#ffad00" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="m43.33 38.67-6.65-6.64 6.65-6.65-4.68-4.68L32 27.34l-6.65-6.64-4.67 4.68 6.64 6.64-6.65 6.65 4.69 4.68 6.65-6.65 6.64 6.65z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/subscription-none.svg b/public/assets/images/icons/yellow/subscription-none.svg
new file mode 100644
index 0000000..e6a4d30
--- /dev/null
+++ b/public/assets/images/icons/yellow/subscription-none.svg
@@ -0,0 +1 @@
+<svg fill="#ffad00" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/public/assets/images/icons/yellow/subscription-quotes.svg b/public/assets/images/icons/yellow/subscription-quotes.svg
new file mode 100644
index 0000000..7323021
--- /dev/null
+++ b/public/assets/images/icons/yellow/subscription-quotes.svg
@@ -0,0 +1 @@
+<svg fill="#ffad00" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path fill="none" d="M0 0h64v64H0z" data-name="Viewbox 64x64"/><path d="M43 27.07h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51H43zm-12.57 0h-9.36c-.04.77-.07 1.56-.07 2.36 0 4.89.8 9.32 2.1 12.57h3.44c-.15-.3-.3-.62-.45-.99-.54-1.3-.97-2.84-1.29-4.51h5.63z"/><path d="M57 47.99c-3.68-.01-5.47-2.31-6-4.99-1-5-2.13-20-2.13-20-.84-7.12-6.08-12.89-12.92-14.52.02-.16.05-.32.05-.48 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .16.03.32.05.48-6.85 1.63-12.08 7.4-12.92 14.52 0 0-1.13 15-2.13 20-.53 2.67-2.32 4.97-6 4.99V54h11.08c.49 3.37 3.42 6 6.92 6s6.43-2.63 6.92-6H57zM13.21 50c1.87-1.42 3.18-3.55 3.71-6.22 1.01-5.03 2.08-19.01 2.19-20.39C19.91 16.89 25.44 12 32 12s12.09 4.89 12.89 11.39c.1 1.38 1.18 15.37 2.19 20.39.53 2.66 1.84 4.79 3.71 6.22z"/></svg> \ No newline at end of file
diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js
index 4af6ed9..86d1f9e 100644
--- a/resources/assets/javascripts/init.js
+++ b/resources/assets/javascripts/init.js
@@ -30,7 +30,6 @@ import Files from './lib/files.js';
import FilesDashboard from './lib/files_dashboard.js';
import Folders from './lib/folders.js';
import Forms from './lib/forms.js';
-import Forum from './lib/forum.js';
import Fullcalendar from './lib/fullcalendar.js';
import Fullscreen from './lib/fullscreen.js';
import GlobalSearch from './lib/global_search.js';
@@ -111,7 +110,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, {
FilesDashboard,
Folders,
Forms,
- Forum,
Fullcalendar,
Fullscreen,
Gettext,
diff --git a/resources/assets/javascripts/lib/forum.js b/resources/assets/javascripts/lib/forum.js
deleted file mode 100644
index d7a625b..0000000
--- a/resources/assets/javascripts/lib/forum.js
+++ /dev/null
@@ -1,853 +0,0 @@
-import { $gettext } from "./gettext";
-import eventBus from "./event-bus.ts";
-
-eventBus.on('studip:set-locale', () => {
- Forum.warning_text = $gettext('Wenn Sie die Seite verlassen, gehen ihre Änderungen verloren!');
-});
-
-const Forum = {
- confirmDialog: null,
- current_area_id: null,
- current_category_id: null,
- seminar_id: null,
- warning_text: 'Wenn Sie die Seite verlassen, gehen ihre Änderungen verloren!',
- clipboard: {},
-
- getTemplate: _.memoize(function(name) {
- return _.template(jQuery("script." + name).html());
- }),
-
- init: function () {
- jQuery('html').addClass('forum');
-
- // make categories and areas sortable
- jQuery('#sortable_areas').sortable({
- axis: 'y',
- items: ">*.movable",
- handle: 'caption',
- stop: function () {
- var categories = {};
- categories.categories = {};
- jQuery(this).find('table').each(function () {
- var name = jQuery(this).data('category-id');
- categories.categories[name] = name;
- });
-
- jQuery.ajax({
- type: 'POST',
- url: STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/savecats?cid=' + STUDIP.Forum.seminar_id),
- data: categories
- });
- }
- });
-
- jQuery('tbody.sortable').sortable({
- axis: 'y',
- items: ">*:not(.sort-disabled)",
- connectWith: 'tbody.sortable',
- handle: '.drag-handle',
- helper: function (e, ui) {
- ui.children().each(function () {
- jQuery(this).width(jQuery(this).width());
- });
- return ui;
- },
-
- stop: function () {
- STUDIP.Forum.saveAreaOrder();
- }
- });
-
- STUDIP.Forum.confirmDialog = STUDIP.Forum.getTemplate('confirm_dialog');
-
- STUDIP.Forum.attachEventHandlers();
- },
-
- approveDelete: function () {
- if (STUDIP.Forum.current_area_id) {
- // hide the area in the dom
- jQuery('tr[data-area-id=' + STUDIP.Forum.current_area_id + ']').remove();
- STUDIP.Forum.closeDialog();
-
- // ajax call to make the deletion permanent
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/delete_entry/'
- + STUDIP.Forum.current_area_id + '?cid=' + STUDIP.Forum.seminar_id), {
- method: 'post',
- data: {'security_token' : STUDIP.CSRF_TOKEN.value},
- success: function (html) {
- jQuery('#message_area').html(html);
- }
- });
-
- STUDIP.Forum.current_area_id = null;
- }
-
- if (STUDIP.Forum.current_category_id) {
- // hide the table in the dom
- jQuery('table[data-category-id=' + STUDIP.Forum.current_category_id + ']').fadeOut();
- STUDIP.Forum.closeDialog();
-
- // move all areas to the default category
- jQuery('table[data-category-id=' + STUDIP.Forum.current_category_id + '] tr.movable').each(function () {
- jQuery('table[data-category-id=' + STUDIP.Forum.seminar_id + ']').append(jQuery(this));
- });
-
- // ajax call to make the deletion permanent
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/remove_category/'
- + STUDIP.Forum.current_category_id + '?cid=' + STUDIP.Forum.seminar_id), {
- method: 'post',
- data: {'security_token' : STUDIP.CSRF_TOKEN.value},
- success: function (html) {
- jQuery('#message_area').html(html);
- }
- });
-
- STUDIP.Forum.current_category_id = null;
- }
- },
-
- deleteCategory: function (category_id) {
- STUDIP.Forum.showDialog($gettext('Sind sie sicher, dass Sie diese Kategorie entfernen möchten? ')
- + $gettext('Alle Bereiche werden dann nach "Allgemein" verschoben!'),
- 'javascript:STUDIP.Forum.approveDelete()',
- 'table[data-category-id=' + category_id +'] td.areaentry');
-
- STUDIP.Forum.current_category_id = category_id;
- },
-
- editCategoryName: function (category_id) {
- var template = STUDIP.Forum.getTemplate('edit_category');
-
- // remove any other open edit fields before adding a new one
- jQuery('table[data-category-id=' + category_id + '] span.edit_category').remove();
-
- jQuery('table[data-category-id=' + category_id + '] span.category_name').hide()
- .parent().append(template({
- category_id : category_id,
- name : jQuery('table[data-category-id=' + category_id + '] span.category_name').text().trim()
- }));
- // jQuery('table[data-category-id=' + category_id + '] span.heading_edit').show();
- },
-
- cancelEditCategoryName: function (category_id) {
- jQuery('table[data-category-id=' + category_id + '] span.edit_category').remove();
- jQuery('table[data-category-id=' + category_id + '] span.category_name').show();
-
- // reset the input field with the unchanged name
- jQuery('table[data-category-id=' + category_id + '] span.heading_edit input[type=text]').val(
- jQuery('table[data-category-id=' + category_id + '] span.category_name').text().trim()
- );
- },
-
- saveCategoryName: function (category_id) {
- var name = {};
- name.name = jQuery('table[data-category-id=' + category_id + '] span.edit_category input[type=text]').val();
-
- if (!jQuery.trim(name.name).length) {
- jQuery('table[data-category-id=' + category_id + '] span.edit_category input[type=text]').val('');
- return;
- }
-
- // display the new name immediately
- jQuery('table[data-category-id=' + category_id + '] span.category_name').text(name.name);
-
- jQuery('table[data-category-id=' + category_id + '] span.edit_category').remove();
- jQuery('table[data-category-id=' + category_id + '] span.category_name').show();
-
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/edit_category/' + category_id + '?cid=' + STUDIP.Forum.seminar_id), {
- type: 'POST',
- data: name
- });
- },
-
- saveAreaOrder: function() {
- // iterate over each category and get the areas there
- var areas = {};
- areas.areas = {};
- jQuery('#sortable_areas').find('table').each(function () {
- var category_id = jQuery(this).data('category-id');
-
- areas.areas[category_id] = {};
-
- jQuery(this).find('tr').each(function () {
- var area_id = jQuery(this).data('area-id');
- areas.areas[category_id][area_id] = area_id;
- });
- });
-
- jQuery.ajax({
- type: 'POST',
- url: STUDIP.URLHelper.getURL('dispatch.php/course/forum/area/save_order?cid=' + STUDIP.Forum.seminar_id),
- data: areas
- });
- },
-
- deleteArea: function (element, area_id) {
- STUDIP.Forum.showDialog($gettext('Sind sie sicher, dass Sie diesen Bereich löschen möchten? ')
- + $gettext('Es werden auch alle Beiträge in diesem Bereich gelöscht!'),
- 'javascript:STUDIP.Forum.approveDelete()',
- 'tr[data-area-id=' + area_id +'] td.areaentry');
-
- STUDIP.Forum.current_area_id = area_id;
- },
-
- addArea: function (category_id) {
- var template = STUDIP.Forum.getTemplate('add_area');
-
- this.cancelAddArea();
-
- jQuery('table[data-category-id=' + category_id + '] tr.add_area').hide();
-
- $(template({
- category_id : category_id,
- })).appendTo('table[data-category-id=' + category_id + ']');
-
- // #FIXME: there should be a better way to initialize a single form
- STUDIP.Forms.initialize();
- },
-
- doAddArea: function() {
- // store the area only if the validity check has passed
- var values = $(this).serializeObject();
-
- // disable submit and cancel buttons, there is no turning back now
- $('.button', this).prop('disabled', true);
-
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/area/add/' + values.category_id + '?cid=' + STUDIP.Forum.seminar_id), {
- type: 'POST',
- data: values,
- success: function(data) {
- // remove the add-form and enable the addition of another area
- $('table[data-category-id=' + values.category_id +'] tr.new_area').remove();
- $('table[data-category-id=' + values.category_id +'] tr.add_area').show();
-
- // insert the new area at the end of the list (more precisely: add the exact position where the add-form has been)
- $(data).appendTo('table[data-category-id=' + values.category_id + ']');
-
- STUDIP.Forum.saveAreaOrder();
- }
- });
-
- return false;
- },
-
- cancelAddArea: function () {
- jQuery('tr.new_area').remove();
- jQuery('tr.add_area').show();
- },
-
- editArea: function (area_id) {
-
- var template = STUDIP.Forum.getTemplate('edit_area');
-
- // disable iconbar
- STUDIP.ActionMenu.closeAll();
- jQuery('tr[data-area-id=' + area_id + '] .action-menu').hide();
-
- // show edit form
- jQuery('tr[data-area-id=' + area_id + '] span.areadata').hide()
- .parent().append(template({
- area_id : area_id,
- name : jQuery('tr[data-area-id=' + area_id + '] span.areaname').text().trim(),
- content : jQuery('tr[data-area-id=' + area_id + '] div.areacontent').data('content')
- }));
- },
-
- cancelEditArea: function (area_id) {
- jQuery('tr[data-area-id=' + area_id + '] span.edit_area').remove();
- jQuery('tr[data-area-id=' + area_id + '] span.areadata').show();
-
- // enable iconbar
- jQuery('tr[data-area-id=' + area_id + '] .action-menu').show();
- },
-
- saveArea: function (area_id) {
- var name = {};
- name.name = jQuery('tr[data-area-id=' + area_id + '] span.edit_area input[type=text]').val();
- name.content = jQuery('tr[data-area-id=' + area_id + '] span.edit_area textarea').val();
-
- // display the new name immediately
- jQuery('tr[data-area-id=' + area_id + '] span.areaname').text(name.name);
-
- // store the modified raw-content used for possible subsequent edits
- jQuery('tr[data-area-id=' + area_id + '] div.areacontent').data('content', name.content);
-
- // store the modified area and get formatted content-text from server
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/area/edit/' + area_id + '?cid=' + STUDIP.Forum.seminar_id), {
- type: 'POST',
- data: name,
- success: function(data) {
- // shorten the description to 150 chars max
- if (data.content.length > 150) {
- jQuery('tr[data-area-id=' + area_id + '] div.areacontent').text(data.content.substr(0, 150)).append('&hellip;');
- } else {
- jQuery('tr[data-area-id=' + area_id + '] div.areacontent').text(data.content);
- }
-
- jQuery('tr[data-area-id=' + area_id + '] span.areaname_edit').hide();
- jQuery('tr[data-area-id=' + area_id + '] span.areaname').parent().parent().show();
-
- // remove edit form
- jQuery('tr[data-area-id=' + area_id + '] span.edit_area').remove();
-
- // enable iconbar
- jQuery('tr[data-area-id=' + area_id + '] .action-icons').show();
- }
- });
-
- },
-
- saveEntry: function(topic_id) {
- var $ = jQuery;
-
- var spanSelector = 'span[data-edit-topic=' + topic_id +']';
-
- var name = $(spanSelector + ' input[name=name]');
- name.data('reset', name.val());
-
- var textarea = $(spanSelector + ' textarea[name=content]');
-
- // make sure HTML stays HTML
- // usually the wysiwyg editor does this automatically,
- // but since there is no submit event the editor does not
- // get notified
- textarea.val(STUDIP.wysiwyg.markAsHtml(textarea.val()));
-
- // remember current textarea value
- textarea.data('reset', textarea.val());
-
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/update_entry/' + topic_id + '?cid=' + STUDIP.Forum.seminar_id), {
- type: 'POST',
- data: jQuery('form[data-topicid='+ topic_id +']').serializeObject(),
-
- error: function(data) {
- alert('Server meldet: ' + data.statusText);
- },
-
- success: function (data) {
- var json = jQuery.parseJSON(data);
- // set the new name and content
- jQuery('span[data-topic-name=' + topic_id +']').html(json.name);
- jQuery('span[data-topic-content=' + topic_id +']').html(json.content);
- STUDIP.Markup.element('span[data-topic-content=' + topic_id +']');
-
- // hide the other stuff
- jQuery('span[data-edit-topic=' + topic_id +']').hide();
- jQuery('span[data-show-topic=' + topic_id +']').show();
-
- }
- });
- },
-
- editEntry: function (topic_id) {
- jQuery('span[data-edit-topic]').hide();
- jQuery('span[data-show-topic]').show();
-
- jQuery('span[data-show-topic=' + topic_id +']').hide();
- jQuery('span[data-edit-topic=' + topic_id +']').show().find('textarea').focus();
- },
-
- cancelEditEntry: function (topic_id) {
- jQuery('span[data-edit-topic=' + topic_id +'] input[name=name]').val(
- jQuery('span[data-edit-topic=' + topic_id +'] input[name=name]').data('reset')
- );
-
- jQuery('span[data-edit-topic=' + topic_id +'] textarea[name=content]').val(
- jQuery('span[data-edit-topic=' + topic_id +'] textarea[name=content]').data('reset')
- );
-
- jQuery('span[data-edit-topic=' + topic_id +']').hide();
- jQuery('span[data-show-topic=' + topic_id +']').show();
- },
-
- newEntry: function() {
- jQuery('#new_entry_button').hide();
-
- jQuery('body').animate({scrollTop: jQuery('div.forum_new_entry').offset().top - 40}, 'slow');
- jQuery('html').animate({scrollTop: jQuery('div.forum_new_entry').offset().top - 40}, 'slow');
- },
-
- cancelNewEntry: function(callback) {
- $(window).off('beforeunload');
-
- if ($('div.forum_new_entry').length) {
- STUDIP.Dialog.confirm(
- $gettext('Sind sie sicher, dass Sie ihren bisherigen Beitrag verwerfen wollen?'),
- function() {
- $('div.forum_new_entry').remove();
- callback();
- },
- function() {}
- );
- } else {
- callback();
- }
-
- jQuery('#new_entry_button').show();
-
- return false;
- },
-
- answerEntry: function() {
- $(window).on('beforeunload', function() {
- return STUDIP.Forum.warning_text;
- });
-
- if (!$('div[data-id=global]').length) {
- STUDIP.Forum.cancelNewEntry(function() {
- var tmpl = STUDIP.Forum.getTemplate('new_entry_box');
- jQuery('#new_entry_button').parent().append(tmpl({
- topic_id: 'global'
- }));
-
- STUDIP.Forum.newEntry();
- });
- }
- },
-
- citeEntry: function(topic_id) {
- $(window).on('beforeunload', function() {
- return STUDIP.Forum.warning_text;
- });
-
- /* Only recreate input-form, if it is different than the current one */
- if (!$('div[data-id=' + topic_id + ']').length) {
- STUDIP.Forum.cancelNewEntry(function() {
- var tmpl = STUDIP.Forum.getTemplate('new_entry_box');
- $('#forumposting_'+ topic_id).parent().append(tmpl({
- topic_id: topic_id
- }));
-
- // watch out for anonymous postings
- var anonymous = jQuery('.anonymous_post[data-profile=' + topic_id + ']').length > 0;
-
- var name = anonymous
- ? $gettext('Anonym')
- : jQuery('span.username[data-profile=' + topic_id + ']').text().trim();
-
- // add content from cited posting in [quote]-tags
- var originalContent = jQuery(
- 'span[data-edit-topic=' + topic_id +'] textarea[name=content]'
- ).val();
-
- var content = STUDIP.Forum.quote(originalContent, name);
-
- var box = jQuery('div.forum_new_entry[data-id=' + topic_id + ']');
- $(box).find('textarea').val(content);
- $(box).insertAfter('form[data-topicid=' + topic_id + ']');
- $(box).addClass('cite_box');
-
- $(box).find('input[type=hidden][name=parent]').val(topic_id);
-
- STUDIP.Forum.newEntry();
- });
- }
- },
-
- quote: function(text, name) {
- // If quoting is changed update these functions:
- // - StudipFormat::markupQuote
- // lib/classes/StudipFormat.php
- // - quotes_encode lib/visual.inc.php
- // - STUDIP.Forum.citeEntry > quote
- // public/plugins_packages/core/Forum/javascript/forum.js
- // - studipQuotePlugin > insertStudipQuote
- // public/assets/javascripts/ckeditor/plugins/studip-quote/plugin.js
-
- var author = '';
- if (name) {
- var writtenBy = $gettext('%s hat geschrieben:');
- author = '<div class="author">'
- + writtenBy.replace('%s', name)
- + '</div>';
- }
- return '<blockquote>' + author + text + '</blockquote><p>&nbsp;</p>';
- },
-
- forwardEntry: function(topic_id) {
- var title = 'WG: ' + jQuery('span[data-edit-topic=' + topic_id +'] [name=name]').attr('value');
- var content = jQuery('span[data-edit-topic=' + topic_id +'] textarea[name=content]').val().trim();
- var is_html = STUDIP.wysiwyg.isHtml(content);
- var nl = is_html ? '<br>' : "\n";
- var text = $gettext('Die Senderin/der Sender dieser Nachricht möchte Sie auf den folgenden Beitrag aufmerksam machen. ')
- + nl + nl
- + $gettext('Link zum Beitrag: ')
- + nl
- + STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/index/'
- + topic_id + '?cid=' + STUDIP.Forum.seminar_id + '&again=yes#' + topic_id)
- + nl + nl
- + content
- + nl + nl;
- if (is_html) {
- text = STUDIP.wysiwyg.markAsHtml(text);
- }
- STUDIP.Dialog.fromURL(STUDIP.URLHelper.getURL('dispatch.php/messages/write'), {
- data: {
- default_body: text,
- default_subject: title
- },
- method: 'post'
- });
- },
-
- postToUrl: function(path, params) {
- // create a form
- var form = jQuery('<form method="post" action="' + path + '" style="display: none">');
- for (var key in params) {
- jQuery(form).append('<textarea name="' + key + '">' + params[key] + '</textarea>');
- }
-
- // append it to the body-element
- jQuery('body').append(form);
-
- // submit it
- jQuery(form).submit();
- },
-
- moveThreadDialog: function (topic_id) {
- var element = jQuery('tr[data-area-id=' + topic_id +'] td.areaentry').addClass('selected'),
- content = jQuery('#dialog_' + topic_id).html();
-
- STUDIP.Dialog.show(content, {
- title: $gettext('Beitrag verschieben'),
- width: 400,
- height: 400,
- origin: element
- });
-
- element.on('dialog-close', function () {
- $(this).removeClass('selected').off('dialog-close');
- });
- },
-
- loadAction: function(element, action) {
- jQuery(element).load(STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/'
- + action + '?cid=' + STUDIP.Forum.seminar_id))
- },
-
- showDialog: function(question, confirm, highlight_element) {
- if (highlight_element !== null) {
- // STUDIP.Forum.highlightedElement = highlight_element;
- jQuery(highlight_element).addClass('selected');
- }
-
- jQuery('body').append(STUDIP.Forum.confirmDialog({
- question: question,
- confirm: confirm
- }));
- },
-
- closeDialog: function() {
- jQuery('#forum td.selected').removeClass('selected');
- jQuery('div.modaloverlay').remove();
- },
-
- setFavorite: function(topic_id) {
- jQuery('#favorite_' + topic_id).load(STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/set_favorite/'
- + topic_id + '?cid=' + STUDIP.Forum.seminar_id));
- jQuery('a.marked[data-topic-id=' + topic_id +']').show();
- return false;
- },
-
- unsetFavorite: function(topic_id) {
- jQuery('#favorite_' + topic_id).load(STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/unset_favorite/'
- + topic_id + '?cid=' + STUDIP.Forum.seminar_id));
- jQuery('a.marked[data-topic-id=' + topic_id +']').hide();
- return false;
- },
-
- adminLoadChilds: function(topic_id) {
- // if there is already data present, remove it (to "close" the current node in the tree)
- if (jQuery('li[data-id=' + topic_id + '] ul').length) {
- jQuery('li[data-id=' + topic_id + '] ul').remove();
- return;
- }
-
- // jQuery('li[data-id=' + topic_id + '] > a.tooltip2').showAjaxNotification();
-
- // load children from server and show them
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/admin/childs/' + topic_id), {
- dataType: 'html',
- success: function(response) {
- jQuery('li[data-id=' + topic_id + ']').append(response);
-
- // jQuery('li[data-id=' + topic_id + '] a.tooltip2').hideAjaxNotification();
-
- // clean up icons
- STUDIP.Forum.checkCutPaste();
- }
- });
- },
-
- cut : function(topic_id) {
- // remove all childs from clipboard
- jQuery('li[data-id=' + topic_id +'] li.selected').each(function(){
- var tid = jQuery(this).data('id');
- jQuery(this).removeClass('selected');
- delete STUDIP.Forum.clipboard[tid];
- });
-
- // add this element to clipboard and mark it as selected
- jQuery('li[data-id=' + topic_id +']').addClass('selected');
- jQuery('li[data-id=' + topic_id + '] > a[data-role=cut]').hide();
- jQuery('li[data-id=' + topic_id + '] > a[data-role=cancel_cut]').show();
- STUDIP.Forum.clipboard[topic_id] = topic_id;
-
- // iterate over every li and remove the paste icon from all li's in the clipboard'
- jQuery('#forum li').each(function() {
- var tid = jQuery(this).data('id');
- if (tid !== null && !STUDIP.Forum.clipboard[tid]) {
- jQuery(this).find('a[data-role=paste]').show();
- } else {
- jQuery(this).find('a[data-role=paste]').hide();
- }
- });
-
- // clean up icons (if necessary)
- STUDIP.Forum.checkCutPaste();
- },
-
- cancelCut: function(topic_id) {
- // remove the selected element from the clipboard and unmark it
- jQuery('li[data-id=' + topic_id +']').removeClass('selected');
- jQuery('li[data-id=' + topic_id + '] a[data-role=cut]').show();
- jQuery('li[data-id=' + topic_id + '] > a[data-role=cancel_cut]').hide();
-
- delete STUDIP.Forum.clipboard[topic_id];
-
- // all children are now valid paste-targets again
- jQuery('li[data-id=' + topic_id + '] a[data-role=paste]').show();
-
- if (Object.keys(STUDIP.Forum.clipboard).length == 0) {
- jQuery('a[data-role=paste]').hide();
- }
-
- // clean up icons (if necessary)
- STUDIP.Forum.checkCutPaste();
- },
-
- paste: function(topic_id) {
- // jQuery('li[data-id=' + topic_id + '] > a.tooltip2').showAjaxNotification();
-
- jQuery.ajax(STUDIP.URLHelper.getURL('dispatch.php/course/forum/admin/move/' + topic_id), {
- data : {
- 'topics' : STUDIP.Forum.clipboard
- },
- type: 'POST',
- success: function() {
- // jQuery('li[data-id=' + topic_id + '] a.tooltip2').hideAjaxNotification();
-
- // remove all pasted entries, they are now elsewhere
- for (var id in STUDIP.Forum.clipboard) {
- jQuery('li[data-id=' + id + ']').remove();
- }
-
- // reload childs after succesful moving
- jQuery('li[data-id=' + topic_id + '] ul').remove();
- STUDIP.Forum.adminLoadChilds(topic_id);
- // reset icons after succesful moving
- STUDIP.Forum.clipboard = {};
- jQuery('a[data-role=cut]').show();
- jQuery('a[data-role=cancel_cut]').hide();
- jQuery('a[data-role=paste]').hide();
- jQuery('li.selected').removeClass('selected');
- }
- });
- },
-
- checkCutPaste: function() {
- jQuery('li.selected').find('li').each(function(){
- var tid = jQuery(this).data('id');
- delete STUDIP.Forum.clipboard[tid];
-
- jQuery(this).removeClass('selected');
- jQuery(this).find('a[data-role=cut]').hide();
- jQuery(this).find('a[data-role=cancel_cut]').hide();
- jQuery(this).find('a[data-role=paste]').hide();
- });
- },
-
- wrapActionElementText: function (element) {
- if (jQuery('span', element).length > 0) {
- return;
- }
- var img = jQuery('img', element).remove();
- var text = jQuery(element).text().trim();
- var span = jQuery('<span>').text(text);
-
- $(element).empty().append(img, span);
- },
-
- openThreadFromOverview: function(topic_id, parent_topic_id, page) {
- var buttonText = $gettext('Thema schließen');
- var element = jQuery('#closeButton-' + topic_id);
-
- STUDIP.Forum.wrapActionElementText(element);
-
- jQuery('img', element).attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/lock-locked.svg');
- jQuery('span', element).text(buttonText);
- jQuery(element).attr('onclick', 'STUDIP.Forum.closeThreadFromOverview("' + topic_id + '", "' + parent_topic_id + '", ' + page + '); return false;');
- jQuery('#img-locked-' + topic_id).hide();
-
- STUDIP.Forum.openThread(topic_id, parent_topic_id, page, false);
-
- STUDIP.ActionMenu.closeAll();
- },
-
- openThreadFromThread: function(topic_id, page) {
- var buttonText = $gettext('Thema schließen');
- jQuery('.closeButtons').text(buttonText);
- jQuery('.closeButtons').attr('onclick', 'STUDIP.Forum.closeThreadFromThread("' + topic_id + '", ' + page + '); return false;');
- jQuery('.closeButtons').closest("li").css('background-image', "url(" + STUDIP.ASSETS_URL + 'images/icons/blue/lock-locked.svg' + ")");
- jQuery('.hideWhenClosed').show();
-
- STUDIP.Forum.openThread(topic_id, topic_id, page, true);
- },
-
- openThread: function(topic_id, redirect, page, showSuccessMessage) {
- jQuery.ajax({
- type: 'GET',
- url: STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/open_thread/' + topic_id + '/' + redirect + '/' + page),
- success: function(data) {
- if (showSuccessMessage == true) {
- jQuery('#message_area').html(data);
- }
- }
- });
-
- return false;
- },
-
- closeThreadFromOverview: function(topic_id, parent_topic_id, page) {
- var buttonText = $gettext('Thema öffnen');
- var element = jQuery('#closeButton-' + topic_id);
-
- STUDIP.Forum.wrapActionElementText(element);
-
- jQuery('img', element).attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/lock-unlocked.svg');
- jQuery('span', element).text(buttonText);
- jQuery(element).attr('onclick', 'STUDIP.Forum.openThreadFromOverview("' + topic_id + '", ' + page + '); return false;');
-
- jQuery('#img-locked-' + topic_id).show();
-
- STUDIP.Forum.closeThread(topic_id, parent_topic_id, page, false);
-
- STUDIP.ActionMenu.closeAll();
- },
-
- closeThreadFromThread: function(topic_id, page) {
- var buttonText = $gettext('Thema öffnen');
- jQuery('.closeButtons').text(buttonText);
- jQuery('.closeButtons').attr('onclick', 'STUDIP.Forum.openThreadFromThread("' + topic_id + '", '+ page +'); return false;');
- jQuery('.closeButtons').closest("li").css('background-image', "url(" + STUDIP.ASSETS_URL + 'images/icons/blue/lock-unlocked.svg' + ")");
- jQuery('.hideWhenClosed').hide();
-
- STUDIP.Forum.cancelNewEntry();
-
- STUDIP.Forum.closeThread(topic_id, topic_id, page, true);
- },
-
- closeThread: function(topic_id, redirect, page, showSuccessMessage) {
-
- jQuery.ajax({
- type: 'GET',
- url: STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/close_thread/' + topic_id + '/' + redirect + '/' + page),
- success: function(data) {
- if (showSuccessMessage == true) {
- jQuery('#message_area').html(data);
- }
- }
- });
-
- return false;
- },
-
- makeThreadStickyFromThread: function(topic_id) {
- jQuery.ajax({
- type: 'GET',
- url: STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/make_sticky/' + topic_id + '/' + topic_id + '/0'),
- success: function(data) {
- jQuery('#message_area').html(data);
- var linkText = $gettext('Hervorhebung aufheben');
- jQuery('#stickyButton').text(linkText);
- jQuery('#stickyButton').attr('onclick', 'STUDIP.Forum.makeThreadUnstickyFromThread("' + topic_id + '"); return false;');
- }
- });
-
- return false;
- },
-
- makeThreadUnstickyFromThread: function(topic_id) {
- jQuery.ajax({
- type: 'GET',
- url: STUDIP.URLHelper.getURL('dispatch.php/course/forum/index/make_unsticky/' + topic_id + '/' + topic_id + '/0'),
- success: function(data) {
- jQuery('#message_area').html(data);
- var linkText = $gettext('Thema hervorheben');
- jQuery('#stickyButton').text(linkText);
- jQuery('#stickyButton').attr('onclick', 'STUDIP.Forum.makeThreadStickyFromThread("' + topic_id + '"); return false;');
- }
- });
-
- return false;
- },
- attachEventHandlers: function () {
- $(document).on('submit', 'form.add_area_form', STUDIP.Forum.doAddArea);
- }
-};
-
-
-// TODO: make TIC and add this to the Stud.IP-Core
-/**
- * found at stackoverflow.com
- * http://stackoverflow.com/questions/946534/insert-text-into-textarea-with-jquery/946556#946556
- */
-jQuery.fn.extend({
- insertAtCaret: function (myValue) {
- return this.each(function () {
- if (document.selection) {
- //For browsers like Internet Explorer
- this.focus();
- var sel = document.selection.createRange();
- sel.text = myValue;
- this.focus();
- } else if (this.selectionStart || this.selectionStart === '0') {
- //For browsers like Firefox and Webkit based
- var startPos = this.selectionStart;
- var endPos = this.selectionEnd;
- var scrollTop = this.scrollTop;
- this.value = this.value.substring(0, startPos) + myValue
- + this.value.substring(endPos, this.value.length);
- this.focus();
- this.selectionStart = startPos + myValue.length;
- this.selectionEnd = startPos + myValue.length;
- this.scrollTop = scrollTop;
- } else {
- this.value += myValue;
- this.focus();
- }
- });
- }
-});
-
-/**
- * Thanks to Tobias Cohen for this function
- * http://stackoverflow.com/questions/1184624/convert-form-data-to-js-object-with-jquery
- */
-jQuery.fn.serializeObject = function() {
- var o = {};
- var a = this.serializeArray();
- jQuery.each(a, function() {
- if (o[this.name] !== undefined) {
- if (!o[this.name].push) {
- o[this.name] = [o[this.name]];
- }
- o[this.name].push(this.value || '');
- } else {
- o[this.name] = this.value || '';
- }
- });
- return o;
-};
-
-export default Forum;
diff --git a/resources/assets/javascripts/lib/jsonapiUtils.js b/resources/assets/javascripts/lib/jsonapiUtils.js
new file mode 100644
index 0000000..b8e1a1d
--- /dev/null
+++ b/resources/assets/javascripts/lib/jsonapiUtils.js
@@ -0,0 +1,15 @@
+import { Deserializer } from 'jsonapi-serializer';
+
+const deserializer = new Deserializer({
+ keyForAttribute: 'snake_case',
+ typeAsAttribute: true
+});
+
+export const deserializeJSONAPIResponse = async response => {
+ try {
+ return await deserializer.deserialize(response);
+ } catch (error) {
+ console.error('Failed to deserialize JSON:API response', error);
+ throw error;
+ }
+};
diff --git a/resources/assets/javascripts/lib/number_formatter.js b/resources/assets/javascripts/lib/number_formatter.js
new file mode 100644
index 0000000..a58c4c5
--- /dev/null
+++ b/resources/assets/javascripts/lib/number_formatter.js
@@ -0,0 +1,21 @@
+export const numberFormatter = (number, digits) => {
+ const lookup = [
+ { value: 1, symbol: "" },
+ { value: 1e3, symbol: "k" },
+ { value: 1e6, symbol: "M" },
+ { value: 1e9, symbol: "G" },
+ { value: 1e12, symbol: "T" },
+ { value: 1e15, symbol: "P" },
+ { value: 1e18, symbol: "E" }
+ ];
+ const regexp = /\.0+$|(?<=\.[0-9]*[1-9])0+$/;
+ const item = lookup.findLast(item => number >= item.value);
+
+ if (!item) {
+ return "0";
+ }
+
+ let formattedNumber = (number / item.value).toFixed(digits).replace(regexp, "").replace(".", ",");
+
+ return formattedNumber + item.symbol;
+}
diff --git a/resources/assets/stylesheets/scss/forms.scss b/resources/assets/stylesheets/scss/forms.scss
index ecf0584..dbed895 100644
--- a/resources/assets/stylesheets/scss/forms.scss
+++ b/resources/assets/stylesheets/scss/forms.scss
@@ -121,7 +121,7 @@ form.default {
vertical-align: top;
input[type=date], input[type=email], input[type=number],
- input[type=password], input[type=text], input[type=time], input[type=tel], input[type=url],
+ input[type=password], input[type=text], input[type=time], input[type=tel], input[type=url], input[type=color],
textarea, select, .ck.ck-editor {
display: block;
margin-top: 0.5ex;
@@ -450,7 +450,7 @@ form.default {
width: auto;
}
input, textarea, select, button {
- display: inline-block;;
+ display: inline-block;
}
}
diff --git a/resources/assets/stylesheets/scss/forum.scss b/resources/assets/stylesheets/scss/forum.scss
index 0810bae..a3ab33d 100644
--- a/resources/assets/stylesheets/scss/forum.scss
+++ b/resources/assets/stylesheets/scss/forum.scss
@@ -1,364 +1,1654 @@
-/* An enhanced style for the forum, Web 2.0 - like*/
+$card-min-width: 250px;
+$card-max-width: 300px;
+
+.forum {
+ hr {
+ border-top: 1px solid $color--divider;
+ border-bottom: none;
+ border-left: none;
+ border-right: none;
+ }
-/* mixins */
-@mixin rounded($radius: 3px) {
- border-radius: $radius;
- -moz-border-radius: $radius;
- -webkit-border-radius: $radius;
-}
+ .forum-table {
+ &.--discussions-index,
+ &.--subscription-index,
+ &.--topics-index {
+ .details-xs {
+ display: none;
+ margin-top: 5px;
+ margin-bottom: 0;
+ }
+ tbody {
+ tr:last-child {
+ td {
+ border-bottom: none;
+ }
+ }
+ }
+ dl {
+ margin: 0;
+
+ dt {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+ }
+
+ dd {
+ margin-left: 0;
+ }
+ }
-$shadow: 1px 1px 3px rgba(0, 0, 0, 0.2);
-@media print {
- #forum {
- .searchbar, div[data-type="page_chooser"],
- .likes, dl.postprofile {
- display: none;
- }
- div.title {
- p.author {
- margin-bottom: 0;
+ a.navigation-link::after {
+ position: absolute;
+ inset: 0;
+ content: '';
+ }
+
+ }
+ &.--subscription-index {
+ .subscription-button {
+ background-color: transparent;
+ border-color: transparent;
}
}
- div.postbody {
- width: 100%;
+ .footer-actions-container {
+ display: flex;
+ align-items: center;
+ gap: 10px;
}
}
-}
-.ui-dialog {
- box-shadow: $shadow
-}
+ .title-with-actions {
+ display: block;
+
+ &__content {
+ display: inline-flex;
+ align-items: start;
+ gap: 10px;
+
+ h3 {
+ flex: 1;
+ }
+ }
+
+ &__link {
+ display: inline-flex;
+ align-items: start;
+ gap: 5px;
+ }
-#forum {
- img.button, input[type=image] {
- vertical-align: middle;
+ &__title {
+ flex: 1;
+ font-size: 16px;
+ font-weight: normal;
+ }
+
+ &__actions-xs {
+ display: none;
+ }
}
- form {
- display: inline;
+ .unread-items-badge {
+ padding: 1px 4px;
+ background: $red;
+ color: $white;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 5px;
+ font-size: smaller;
}
- span.highlight {
- background-color: var(--activity-color-40);
- border: 1px solid var(--activity-color-40);
- @include rounded;
+ .text-highlight {
+ &::selection,
+ *::selection {
+ background-color: $yellow;
+ color: $color--font-primary;
+ }
}
- .searchbar {
- text-align: left;
- input[name=searchfor] {
- width: 90%;
+ .profile-image-container {
+ width: 50px;
+ height: 50px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: hidden;
+ border-radius: 50%;
+ border: 1.5px solid white;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
}
+ }
+ &__container {
+ display: grid;
+ gap: 20px;
+ grid-template-columns: 5fr 2fr;
}
- .forum_header {
- background-color: var(--content-color);
- color: var(--white);
- margin: 0;
- padding: 0;
- .button {
+ .card {
+ box-sizing: border-box;
+ border: solid 1px $color--content-box-border;
+ margin: 0 0 15px;
+ min-width: auto;
+
+ &__header {
+ background-color: $color--fieldset-header;
+ font-size: 16px;
+ padding: 10px;
+
+ &--with-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+
+ .actions {
+ display: flex;
+ gap: 10px;
+ }
+ }
+ }
+
+ &__title {
margin: 0;
}
+ &__body {
+ padding: 10px;
+ }
}
- .heading {
- display: block;
- margin: 1px 4px 4px 6px;
- text-transform: uppercase;
+ .header {
+ display: flex;
+ background-color: $color--fieldset-header;
+
+ &.--sticky-top {
+ position: sticky;
+ top: 50px;
+ z-index: 10;
+ }
+
+ .flag {
+ width: 10px;
+ }
+
+ &__content {
+ padding: 10px 15px;
+ flex: 1 1 0%;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+
+ &--with-actions {
+ .actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+ }
+
+ .go-back-link {
+ margin-top: 4px;
+ }
+
+ h2 {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 500;
+ }
+ }
}
- table.forum {
- td.selected {
- background-color: var(--activity-color-40);
+ .icon-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: white;
+ border: 1px solid $base-color;
+ color: $base-color;
+ border-radius: 5px;
+ padding: 7px;
+ cursor: pointer;
+
+ &.--with-label {
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+
+ &:hover {
+ background-color: $content-color-10;
+ }
+
+ &:disabled {
+ border-color: $color--button-inactive-border;
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .label {
+ margin-left: 5px;
}
}
- td.postings {
- vertical-align: middle;
- text-align: center;
- width: 80px;
+ button.style-less {
+ background: no-repeat;
+ border: none;
+ padding: 0;
}
- td.answer {
- width: 300px;
+ .button {
+ &.--with-icon {
+ display: inline-flex;
+ gap: 10px;
+ align-items: center;
+ }
- img {
- vertical-align: text-bottom;
+ .icon-hover {
+ display: none;
}
- }
- .area_title {
- padding: 0 5px;
- font-weight: bold;
- text-transform: uppercase;
+ &:hover {
+ .icon-default {
+ display: none;
+ }
+ .icon-hover {
+ display: inline-block;
+ }
+ }
}
- .area_input {
- display: block;
- padding: 0 5px;
+ .v-select {
+ margin: 0;
+ min-width: 160px;
}
- .add_area_form {
- display: block;
- padding: 0 5px;
- text-align: center;
+ .empty-forum {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+ gap: 10px;
+ color: $color--font-primary;
+ padding: 0 20px;
+
+ .forum-illustration {
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ img {
+ width: 90%;
+ height: auto;
+ }
+ }
+
+ p {
+ color: $color--font-secondary;
+ }
+
+ .buttons-container {
+ margin-top: 20px;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 15px;
+
+ button {
+ margin: 0;
+ }
+ }
}
- td.add_area {
- font-weight: bold;
- font-size: 16pt;
- text-align: right;
- padding-right: 12px;
+ .topic-overview {
+ display: flex;
- img {
- margin-bottom: -3px;
+ .flag {
+ width: 5px;
+ margin-right: 15px;
+ }
+
+ .content {
+ flex: 1 1 0%;
}
- span {
- font-size: 10pt;
+ h3 {
+ margin-top: 0;
+ margin-bottom: 5px;
+ font-size: 16px;
font-weight: normal;
}
+
+ p {
+ color: $color--font-secondary;
+ }
}
- td.add_area:hover {
- cursor: pointer;
+ .discussion-overview {
+ h3 {
+ margin-top: 0;
+ font-size: 16px;
+ font-weight: normal;
+ }
+
+ p {
+ color: $color--font-secondary;
+ }
- span {
- color: var(--red-80);
+ .discussion-category {
+ display: flex;
+ align-items: center;
+ gap: 5px;
}
}
- .icon img {
- vertical-align: middle;
+ .forum-form {
+ flex: 1;
+ padding: 5px 10px;
+ .multi-select-input .vs__dropdown-toggle {
+ max-height: fit-content;
+ }
+
+ fieldset:not(.undecorated) > :not(legend, table) {
+ max-width: calc(100% - 1px);
+ }
+
+ .inputs-container {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ flex-wrap: wrap;
+ }
}
- span.areaname {
- display: block;
- margin-right: 55px;
- font-weight: bold;
+ .tags-container {
+ padding: 0;
+ list-style-type: none;
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+
+ &__tag {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $color--font-secondary;
+ font-size: 10px;
+ border: 1px solid $color--action-menu-border;
+ padding: 1px 5px;
+ border-radius: 4px;
+ }
}
- span.threadauthor {
- float: left;
- width: 70%;
+ table.topics td {
+ vertical-align: top;
}
- .posting {
- height: 100%;
- margin: 0 0 10px 0;
- padding: 5px;
- background-color: var(--content-color-20);
+ .topic-cards-container {
+ padding: 0;
+ list-style-type: none;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax($card-min-width, $card-max-width));
+ grid-gap: 15px;
+
+ &.--fill-free-space {
+ grid-template-columns: repeat(auto-fit, minmax($card-min-width, 1fr));
+ }
+
+ .card-group {
+ background-color: $color--tile-background;
+ border: 1px solid $color--button-inactive-border;
+ padding-top: 4px;
+ padding-right: 4px;
+ margin-left: 4px;
+ margin-bottom: 4px;
+
+ .topic-card {
+ margin-left: -4px;
+ margin-bottom: -4px;
+ }
+ }
}
- .real_posting {
+ .topic-card {
+ background-color: $color--tile-background;
+ border: 1px solid $color--button-inactive-border;
+ outline: transparent;
+ height: 100%;
+ min-height: 180px;
display: flex;
+ transition: 0.2s;
+
+ &__flag {
+ width: 10px;
+ }
+
+ &.--new-topic {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ a {
+ height: fit-content;
+ }
+ }
+
+ .unread-items-badge {
+ position: relative;
+ z-index: 1;
+ }
+
+ &__title {
+ margin-top: 0;
+ margin-bottom: 10px;
+ font-size: 16px;
+ font-weight: normal;
+ }
+
+ p {
+ opacity: 60%;
+ }
+
+ &__content {
+ flex: 1;
+ padding: 10px 15px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 150px;
+ overflow: hidden;
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 5px;
+ position: relative;
+ z-index: 1;
+ }
+
+
+ &:hover:not(.--new-topic),
+ &:focus:not(.--new-topic) {
+ background-color: $color--tile-background-hover;
+ }
+
+ &.--with-hover-style:hover,
+ &.--with-hover-style:focus {
+ outline-color: var(--forum-topic-card-hover-border-color);
+ outline-style: solid;
+ border-color: var(--forum-topic-card-hover-border-color);
+ }
}
- @keyframes border-pulsate {
- 0% { border-color: rgba(255, 255, 153, 1); }
- 50% { border-color: rgba(255, 255, 153, 0); }
- 100% { border-color: rgba(255, 255, 153, 1); }
+ .post {
+ display: flex;
+ transition: background-color 1s ease;
+
+ &.--highlight {
+ background-color: $content-color-10;
+ }
+
+ &__body {
+ padding: 25px;
+ flex: 1;
+ display: flex;
+ gap: 15px;
+ }
+
+ &__unread {
+ width: 5px;
+ margin-right: 15px;
+ background-color: $red;
+ }
+
+ &__content {
+ flex: 1;
+ }
+
+ &__text {
+ p {
+ color: $color--font-primary;
+ }
+
+ img {
+ max-width: 100% !important;
+ height: auto !important;
+ }
+ }
+
+ &__author-name-container {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+ color: $color--font-secondary;
+
+ .author-name {
+ font-size: 14px;
+ font-weight: bold;
+ }
+
+ &.--xl {
+ display: flex;
+ }
+
+ &.--xs {
+ display: none;
+ }
+ }
+
+ &__author-avatar {
+ position: sticky;
+ top: 50px;
+ z-index: 1;
+
+ .dropdown__content {
+ right: auto;
+ left: 0;
+ }
+ }
+
+ &__footer {
+ margin-top: 10px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+ }
+
+ .opengraph-urls {
+ margin-top: 10px;
+ margin-bottom: 20px;
+ }
}
- div.highlight {
- border: 4px solid var(--activity-color-40);
- animation: border-pulsate 2s 5;
+ .forum-quote {
+ blockquote {
+ border: none;
+ position: relative;
+ padding-left: 2rem;
+ color: $color--font-primary;
+ white-space: pre-line;
+ background-color: $color--tile-background !important;
+
+ &:before {
+ color: $color--font-secondary;
+ position: absolute;
+ font-size: 4rem;
+ width: 2rem;
+ height: 2rem;
+ content: '“';
+ left: 4px;
+ top: -1rem;
+ }
+
+ a {
+ background: unset !important;
+ color: unset !important;
+ }
+
+ a.ck-link_selected {
+ background: unset !important;
+ color: unset !important;
+ }
+ }
}
- .postbody {
+ .post-reactions {
position: relative;
- padding: 0pt 5px;
- margin: 5px 0 0 0;
- flex: 1;
- min-width: 0;
- text-align: left;
- }
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ &__create-button {
+ display: inline-flex;
+ cursor: pointer;
+ background: none;
+ border: navajowhite;
+ padding: 0;
+ align-items: center;
+ gap: 5px;
+
+ p {
+ margin: 0;
+ }
- .buttons {
- clear: both;
- width: 100%;
- text-align: center;
- padding-top: 5px;
+ .add-reaction-icon {
+ width: 20px;
+ height: auto;
+ fill: $color--font-secondary;
+ transition: fill 0.2s;
+ }
+
+ &:hover {
+ .add-reaction-icon {
+ fill: $base-color;
+ }
+ }
+ }
+
+ &__container {
+ position: absolute;
+ top: -50px;
+ left: 12px;
+ transform: translateX(-50%);
+ z-index: 10;
+ display: flex;
+ background: white;
+ border: 1px solid $base-color;
+ border-radius: 5px;
+ cursor: pointer;
+ overflow: hidden;
+ box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
+
+ button {
+ background: none;
+ border: none;
+ padding: 6px 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ transition: 0.3s;
+
+ &.--active {
+ background-color: $base-color-20;
+ }
+ }
+
+ button:hover {
+ cursor: pointer;
+ background-color: $content-color-10;
+ }
+ }
}
- div.title {
- text-align: left;
- float: left;
- width: 100%;
+ .post-form-container {
+ padding: 25px;
}
- .title {
- font-weight: bold;
+ .discussion {
+ background-color: $color--main-navigation-background;
+
+ &__status,
+ &__body,
+ &__form-container {
+ padding: 25px;
+ }
+
+ &__body {
+ display: flex;
+ gap: 15px;
+ }
+
+ &__status {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 10px;
+ color: $color--font-secondary;
+ }
+
+ .post__author-image {
+ width: 50px !important;
+ height: 50px !important;
+ }
}
- div.postbody span.icons {
- float: right;
- min-width: 3%;
+ .posts-container {
+ display: grid;
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+
+ hr {
+ width: 100%;
+ }
}
- div.postbody .content {
- overflow: hidden;
- clear: both;
+ .post-form {
+ max-width: calc(100% - 1px);
+ &__author {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 15px;
+ }
+
+ &__author-name {
+ color: $color--font-secondary;
+ font-weight: 600;
+ }
+
+ &__author-image {
+ width: 25px;
+ height: 25px;
+ }
+
+ .quote-container {
+ position: relative;
+
+ .remove-quote-button {
+ display: none;
+ position: absolute;
+ cursor: pointer;
+ bottom: 0;
+ right: 0;
+ padding: 3px;
+ align-items: center;
+ justify-content: center;
+ background-color: $color--tile-background;
+ border: 1px solid $color--action-menu-border;
+ border-radius: 4px;
+ transition: background-color 1s ease;
+ z-index: 1;
+
+ &:hover,
+ &:focus {
+ background-color: $color--tile-background-hover;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ .remove-quote-button {
+ display: flex;
+ }
+ }
+ }
}
- p.author {
- margin: 2px 0px 8px 0px;
+ .user-avatar-dropdown {
+ &__preview {
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+
+ &:hover,
+ &:focus,
+ &.active {
+ position: relative;
+ z-index: 1;
+
+ img.user-profile {
+ box-shadow: 0 2px 3px rgb(0 0 0 / 0.2);
+ }
+ }
+
+ img.user-profile {
+ width: 25px;
+ height: auto;
+ border-radius: 100%;
+ border: 1.5px solid white;
+ transition: all .4s ease-in-out;
+ }
+ }
}
- .content {
- clear: both;
+ .forum-users-dropdown {
+ padding: 10px;
+ min-width: 200px;
+ max-width: 80vw;
+ max-height: 50vh;
+ overflow-y: scroll;
+
+ hr {
+ margin: 10px 0;
+ }
}
- span.username {
- font-weight: bold;
+ .user-group {
+ &--moderators {
+ img.user-profile {
+ border-color: $base-color;
+ }
+ }
+
+ .user-avatar {
+ img.user-profile {
+ width: 40px;
+ height: 40px;
+ }
+ }
+
+ &__title {
+ font-size: 14px;
+ font-weight: bold;
+ color: $color--font-primary;
+ margin: 0;
+ }
+
+ &__list {
+ margin-top: 10px;
+ list-style: none;
+ padding: 0;
+
+ li {
+ position: relative;
+ .user-avatar {
+ margin-left: -10px !important;
+ margin-right: -10px !important;
+ overflow: hidden;
+ }
+ }
+
+ .user-item {
+ margin-left: -10px !important;
+ margin-right: -10px !important;
+ padding: 4px 10px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ transition: background-color 0.2s ease-out;
+
+ &:hover,
+ &:focus {
+ background-color: $color--tile-background-hover;
+ }
+
+ &__user {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ img {
+ width: 25px;
+ height: 25px;
+ border-radius: 100%;
+ border: 1.5px solid white;
+ transition: all .4s ease-in-out;
+ }
+
+ p {
+ text-wrap: auto;
+ margin: 0;
+ }
+ }
+ }
+
+ .hide-avatar,
+ .show-avatar {
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ margin: -4px;
+ padding: 4px;
+ }
+
+ .hide-avatar {
+ position: absolute;
+ right: 0;
+ top: 10px;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: $content-color-10;
+ }
+ }
+ }
}
- .postprofile {
- @media only screen and (max-width: 768px) {
- display: none !important;
+
+ .forum-members {
+ list-style-type: none;
+ display: flex;
+ align-items: center;
+ padding: 0;
+
+ li {
+ &:not(:first-child) {
+ margin-left: -10px;
+ }
+
+ .moderator img.user-profile {
+ border: 2px solid $base-color;
+ }
+
+ .remained-users {
+ &__button {
+ padding: 0;
+ margin: 0;
+ background: none;
+ border: none;
+ }
+
+ &__count {
+ cursor: pointer;
+ font-size: 14px;
+ color: $color--font-secondary;
+ background-color: $color--fieldset-header;
+ width: 25px;
+ height: 25px;
+ border-radius: 100%;
+ border: 1.5px solid white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ }
}
- border-left: 1px solid var(--white);
- margin: 0;
- padding: 4px;
- height: 100%;
- width: 180px;
- dd, dt {
- padding: 0pt;
- margin: 0pt;
+
+ .user-avatar {
+ img.user-profile {
+ box-shadow: none !important;
+ }
+
+ &__actions {
+ li {
+ margin-left: 0;
+ }
+ }
}
+ .user-group__list li {
+ margin-left: 0;
+ }
}
- span.buttons {
- display: block;
- clear: both;
- text-align: center;
- width: 78%;
+ .with-ballon-action {
+ margin: -5px;
+ padding: 10px 5px;
}
- .clear {
- display: block;
- clear: both;
+ .ballon-action {
+ z-index: 10;
+ position: absolute;
+ display: none;
+ background: white;
+ border: 1px solid $base-color;
+ color: $base-color;
+ border-radius: 5px;
+ gap: 5px;
+ cursor: pointer;
+ overflow: hidden;
+ box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
+
+ button {
+ background: none;
+ border: none;
+ padding: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ button:hover {
+ cursor: pointer;
+ background-color: $content-color-10;
+ }
}
- textarea {
- width: 100%;
- height: 20em;
+ .timeline-container {
+ position: sticky;
+ top: 50px;
}
- .editor_toolbar {
+ .discussion-timeline-table {
width: 100%;
+ height: 350px;
+
+ tr td:first-child {
+ width: 6px;
+ background-color: $color--tile-title-background;
+ }
+
+ tr:first-child td:first-child {
+ background-color: $base-color;
+ }
+
+ tr td:nth-child(2) {
+ padding: 0 10px;
+ }
+
+ time, p {
+ color: $color--font-secondary;
+ margin: 0;
+ }
+
+ time {
+ font-weight: 600;
+ }
+
+ p {
+ font-size: small;
+ }
+
+ td.bg-new-activity {
+ background-color: $red !important;
+ }
+ }
+
+ .drag-handle {
+ display: inline-block;
+ width: 6px;
+ height: 20px;
+ margin-top: 5px;
+ background-size: auto 20px;
+ cursor: move;
+ }
+
+ .studip-icons-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+ padding: 15px;
+ justify-content: space-between;
+ border: thin solid $color--button-inactive-border;
+ max-width: calc(48em - 30px);
+
+ .icon {
+ border-radius: 5px;
+ padding: 0.5rem;
+ cursor: pointer;
+ border: 1px solid $light-gray-color-20;
+ outline: transparent;
+ background: none;
+ transition: background-color 0.2s ease;
+
+ &:hover:not(.active) {
+ background-color: $light-gray-color-20;
+ }
+
+ &.disabled {
+ opacity: 0.25;
+ }
+
+ &.active {
+ border-color: $green-40;
+ outline-color: $green-40;
+ outline-style: solid;
+ }
+ }
}
- a.marked div {
+ .post-reactions-container {
+ margin-top: 10px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+
+ .post-reaction {
+ padding: 4px 6px;
+ background: $color--gray-7;
+ border-radius: 6px;
+ border: thin solid $color--gray-5;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+
+ &.--active {
+ background-color: $base-color-20;
+ border-color: $base-color-60;
+ }
+ }
+
+ .html-emoji {
+ font-family: apple color emoji, segoe ui emoji, notocoloremoji, segoe ui symbol, android emoji, emojisymbols, emojione mozilla;
+ }
+ }
+
+ .forum-subscriptions-dropdown {
+ text-align: left;
+ white-space: pre-wrap;
+
+ .dropdown__items {
+ max-width: 300px;
+ li {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ padding: 10px 15px;
+
+ .subscription-option {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 5px;
+ }
+
+ .option-title {
+ font-size: 14px;
+ color: $base-color;
+ font-weight: 400;
+ margin: 0;
+ }
+
+ p {
+ color: $color--font-secondary;
+ margin-top: 6px;
+ font-size: small;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ &.all {
+ background-color: $green-20;
+ cursor: default;
+ }
+
+ &.replies_only {
+ background-color: $activity-color-20;
+ cursor: default;
+ }
+
+ &.none {
+ background-color: $dark-gray-color-20;
+ cursor: default;
+ }
+
+ &.--active {
+ background-color: $dark-gray-color-10;
+ cursor: default;
+ }
+
+ &.--disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+ }
+
+ .subscription-button {
+ white-space: nowrap;
+ display: inline-flex;
+ gap: 6px;
+ align-items: center;
+ }
+ }
+
+ .discussion-closed {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ opacity: 0.6;
+ }
+
+ .use-utility-classes {
+ .flex {
+ display: flex;
+ }
+ .flex-1 {
+ flex: 1;
+ }
+ .flex-col {
+ flex-direction: column;
+ }
+ .inline-flex {
+ display: inline-flex;
+ }
+ .items-start {
+ align-items: start;
+ }
+ .items-center {
+ align-items: center;
+ }
+ .items-baseline {
+ align-items: baseline;
+ }
+ .space-between {
+ justify-content: space-between;
+ }
+ .justify-start {
+ justify-content: start;
+ }
+ .gap-5 {
+ gap: 5px;
+ }
+ .gap-10 {
+ gap: 10px;
+ }
+ .gap-20 {
+ gap: 20px;
+ }
+ .gap-40 {
+ gap: 40px;
+ }
+ .mr-10 {
+ margin-right: 10px;
+ }
+ .mr-5 {
+ margin-right: 5px;
+ }
+ .w-100 {
+ width: 100%;
+ }
+ .p-10 {
+ padding: 10px;
+ }
+ .py-10 {
+ padding: 10px 0;
+ }
+ .border-b-0 {
+ border-bottom: 0 !important;
+ }
+ .mb-1 {
+ margin-bottom: 1rem !important;
+ }
+ .mt-10 {
+ margin-top: 10px;
+ }
+ .mb-10 {
+ margin-bottom: 10px;
+ }
+
+ .align-baseline {
+ vertical-align: baseline;
+ }
+
+ .bg-light-gray {
+ background-color: $light-gray-color-40;
+ }
+
+ .line-clamp-1 {
+ display: -webkit-box;
+ -webkit-line-clamp: 1;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .line-clamp-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .line-clamp-3 {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .nowrap {
+ white-space: nowrap;
+ }
+
+ .p-0 {
+ padding: 0;
+ }
+
+ .m-0 {
+ margin: 0;
+ }
+
+ .list-none {
+ list-style-type: none;
+ }
+
+ .max-w-full {
+ max-width: 100%;
+ }
+
+ .cursor-pointer {
+ cursor: pointer;
+ }
+ .relative {
+ position: relative;
+ }
+
+ .z-1 {
+ z-index: 1;
+ }
+ .color-font-secondary {
+ color: $color--font-secondary;
+ }
+ }
+
+ .vs__actions {
cursor: pointer;
- @include background-icon(staple, $size: 32px);
- position: absolute;
- top: -10px;
- right: 10px;
- height: 32px;
- width: 32px;
- transform: rotate(140deg);
+ align-items: center !important;
+ }
+ .vs__selected {
+ margin: 4px 4px 0 !important;
+ padding: 1px 5px !important;
+ background: transparent;
+ align-items: center;
+ gap: 5px;
+ border: thin solid $color--divider;
+ border-radius: 5px;
+ }
+ .vs__search,
+ .vs__search:focus,
+ .vs__selected-options {
+ font-size: inherit;
}
- .new_posting {
- position: absolute;
- top: 10px;
- right: 10px;
+ .discussion-badges-container {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin-top: 5px;
+ margin-bottom: 10px;
}
- div.action-icons {
- display: none;
+ .badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ border: thin solid $color--divider;
+ padding: 1px 5px;
+ border-radius: 5px;
+ font-size: smaller;
+
+ .action {
+ cursor: pointer;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
}
- dd.posting_icons {
- padding-top: 5px;
- img {
- vertical-align: bottom;
+ .topic-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 1px 5px;
+ font-size: smaller;
+ }
+
+ .search-container {
+ background-color: $color--tile-background;
+ padding: 30px;
+
+ h1 {
+ font-weight: 400;
}
- a {
- margin-right: 5px;
+ hr {
+ margin: 20px 0;
+ border-color: $color--gray-5;
+ }
+
+ .search-controls {
+ margin-top: 10px;
+ display: flex;
+ gap: 10px;
+
+ .search-input-container {
+ flex: 1;
+
+ input {
+ max-width: unset;
+ }
+ }
+
+ button {
+ padding-top: 5px;
+ padding-bottom: 5px;
+ height: fit-content;
+ }
+
+ input.search-input {
+ max-width: unset;
+ }
+ }
+
+ .filter-summary-container {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ margin-top: 10px;
+ margin-bottom: 10px;
+ }
+
+ .badge {
+ border-color: $color--gray-5;
+ }
+
+ .toggle-filter-button {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ background: none;
+ border: none;
+ padding: 0;
+ }
+
+ .filter-controls {
+ margin-top: 20px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: 10px;
+
+ .date-inputs-container {
+ display: flex;
+ gap: 10px;
+ align-items: start;
+ }
+
+ .vs__dropdown-toggle {
+ max-height: fit-content;
+ }
+
+ label {
+ margin: 0;
+ }
}
}
- a.tooltip2 {
- color: black;
- cursor: help;
- display: inline-block;
- outline: none;
- position: relative;
- text-decoration: none;
-
- span {
- display: inline-block;
- margin-bottom: 9px;
- background-image: -moz-linear-gradient(top, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0));
- background-image: -webkit-gradient(linear, 0 0, 0 100%, from(rgba(255, 255, 255, 0.5)), to(rgba(255, 255, 255, 0)));
- background-image: -webkit-linear-gradient(top, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0));
- background-image: -o-linear-gradient(top, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0));
- background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0));
- background-repeat: repeat-x;
- filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80ffffff', endColorstr='#00ffffff', GradientType=0);
- background-color: var(--dark-gray-color-20);
- border: 2px solid var(--dark-gray-color-30);
- border-radius: 4px;
- top: 20%;
- bottom: 0;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 0 rgba(255, 255, 255, 0.5) inset;
- font-size: 10pt;
- font-weight: normal;
- margin-left: 0px;
- opacity: .95;
- padding: 10px;
- position: absolute;
- text-align: left;
- text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
- visibility: hidden;
- white-space: normal;
- width: 400px;
- z-index: 999;
- clear: both;
- }
- }
-
- a.tooltip2:hover span {
- visibility: visible;
- }
-
- li.selected {
- background-color: var(--base-color-20);
- }
-
- div.posting.bg2 {
- flex: 1;
+ .search-result-container {
+ margin-top: 20px;
+
+ h2 {
+ font-weight: 400;
+ }
}
- #tutorBreadcrumb {
- float: left;
- margin-bottom: 1em;
- font-size: 1.4em;
+ ul.breadcrumb {
+ padding: 0;
+ list-style: none;
+
+ li {
+ display: inline;
+ font-size: 18px;
+ }
+
+ li+li:before {
+ padding: 0 8px;
+ content: "/";
+ }
}
- #page-chooser {
- float: right;
- padding-right: 10px;
- margin-bottom: 1em;
+ /*
+ vue Transition --start--
+ */
+ .fade-enter-active,
+ .fade-leave-active {
+ transition: opacity 0.5s ease;
+ }
+
+ .fade-enter-from,
+ .fade-leave-to {
+ opacity: 0;
+ }
+
+ .fade-up-enter-active {
+ transition: all 0.2s ease-out;
+ }
+
+ .fade-up-leave-active {
+ transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
+ }
+
+ .fade-up-enter-from,
+ .fade-up-leave-to {
+ transform: translateY(20px);
+ opacity: 0;
+ }
+ /*
+ vue Transition --end--
+ */
+
+ @media (max-width: 1200px) {
+ &__container {
+ grid-template-columns: 1fr;
+ }
+
+ .timeline-container {
+ display: none;
+ }
+ }
+
+ @media only screen and (max-width: 600px) {
+ .topic-cards-container {
+ grid-template-columns: 1fr;
+ }
+
+ .forum-table {
+ &.--discussions-index,
+ &.--subscription-index,
+ &.--topics-index {
+ th:not(:first-child),
+ td:not(:first-child),
+ col:not(:first-child) {
+ display: none;
+ }
+
+ .details-xs {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+
+ dl {
+ display: flex;
+ gap: 15px;
+ flex-wrap: wrap;
+ }
+ }
+ }
+ }
+
+ .title-with-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+
+ &__content {
+ flex: 1;
+ }
+
+ &__actions-xs {
+ display: inline-block;
+ }
+ }
+
+ .discussion__status {
+ padding: 15px;
+ }
+
+ .post {
+ &__body {
+ padding: 10px;
+ flex-direction: column;
+ }
+
+ &__author-image {
+ width: 35px;
+ height: 35px;
+ }
+
+ &__author {
+ display: flex;
+ gap: 10px;
+ }
+
+ &__author-name-container {
+ &.--xl {
+ display: none;
+ }
+
+ &.--xs {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: start;
+ justify-content: space-around;
+ height: 100%;
+ }
+ }
+ }
+
+ .post-form-container {
+ padding: 10px;
+ }
+
+ .discussion .forum-members img {
+ width: 30px !important;
+ height: 30px !important;
+ }
+
+ .header {
+ position: unset !important;
+
+ &__content {
+ padding: 10px;
+ flex-wrap: wrap;
+
+ .actions {
+ flex: 1;
+ justify-content: end;
+ }
+ }
+ }
+
+ .search-container {
+ padding: 15px;
+ }
+
+ .empty-forum {
+ padding: 0 5px;
+ }
+
+ .forum-form {
+ padding: 5px ;
+ }
}
}
-.forum_new_entry {
- form.default {
- footer {
- margin-bottom: 10px;
+.fullscreen-mode .forum {
+ .timeline-container {
+ top: 120px;
+ }
+
+ .post,
+ .discussion {
+ &__author-image {
+ top: 120px;
}
}
}
+
+.studip-dialog.no-default-buttons .ui-dialog-content form > :not(footer[data-dialog-button]):last-of-type {
+ margin-bottom: 15px;
+}
diff --git a/resources/assets/stylesheets/scss/links.scss b/resources/assets/stylesheets/scss/links.scss
index 33da274..03fe33a 100644
--- a/resources/assets/stylesheets/scss/links.scss
+++ b/resources/assets/stylesheets/scss/links.scss
@@ -43,3 +43,14 @@ a.link-edit {
a img {
border: 0;
}
+
+a.styleless {
+ color: unset;
+ text-decoration: unset;
+
+ &:hover,
+ &:active,
+ &:active {
+ color: unset;
+ }
+}
diff --git a/resources/assets/stylesheets/scss/personal-notifications.scss b/resources/assets/stylesheets/scss/personal-notifications.scss
index f506e28..6ce6d27 100644
--- a/resources/assets/stylesheets/scss/personal-notifications.scss
+++ b/resources/assets/stylesheets/scss/personal-notifications.scss
@@ -206,18 +206,18 @@
@include background-icon('accept', 'clickable');
background-repeat: no-repeat;
background-position: right 8px center;
-
+
&:hover {
@include background-icon('accept', 'attention');
}
-
+
margin: 0;
}
a.enable-desktop-notifications {
@include background-icon('notification', 'clickable');
background-repeat: no-repeat;
background-position: right 8px center;
-
+
&:hover {
@include background-icon('notification', 'attention');
}
@@ -236,4 +236,10 @@
}
.item:hover .options.hidden { visibility: visible; }
}
+
+ .html-emoji {
+ font-family: apple color emoji, segoe ui emoji, notocoloremoji, segoe ui symbol, android emoji, emojisymbols, emojione mozilla;
+ font-size: 20px;
+ margin-right: 10px;
+ }
}
diff --git a/resources/assets/stylesheets/scss/select.scss b/resources/assets/stylesheets/scss/select.scss
index 3285081..a5f51b7 100644
--- a/resources/assets/stylesheets/scss/select.scss
+++ b/resources/assets/stylesheets/scss/select.scss
@@ -46,3 +46,40 @@ form.default .studip-v-select .vs__selected {
padding: 0;
margin: 2px 2px 0;
}
+
+
+
+.vs {
+ &__open-indicator,
+ &__clear,
+ &__deselect {
+ fill: $base-color !important;
+ }
+
+ &__dropdown-toggle,
+ &__dropdown-menu {
+ border: 1px solid $light-gray-color-40 !important;
+ }
+
+ &__dropdown-menu {
+ margin-top: 5px !important;
+ box-shadow: none !important;
+ }
+
+ &__deselect,
+ &__clear {
+ height: 15px;
+ width: 15px;
+ $icon: icon-path(decline, clickable);
+ background-image: url("#{$icon}") !important;
+ background-size: 15px 15px !important;
+
+ svg {
+ display: none;
+ }
+ }
+}
+
+.multiselect__tags {
+ background-color: red !important;
+}
diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss
index 21ce5ab..16075ae 100644
--- a/resources/assets/stylesheets/studip.scss
+++ b/resources/assets/stylesheets/studip.scss
@@ -650,3 +650,253 @@ input.allow-plaintext-toggle {
}
}
}
+
+.links-preview {
+ &__item {
+ min-height: 150px;
+ border: solid 1px $color--content-box-border;
+ border-radius: 5px;
+ overflow: hidden;
+ }
+
+ &__controls {
+ display: flex;
+ justify-content: end;
+
+ button {
+ cursor: pointer;
+ background-color: transparent;
+ border: none;
+ padding: 7px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background-color: $content-color-10;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+ }
+}
+
+.og-preview {
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ grid-gap: 10px;
+
+ &__image-container {
+ max-height: 150px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ -o-object-fit: cover;
+ object-fit: cover;
+ }
+ }
+
+ &__details {
+ padding: 20px 10px;
+ }
+
+ &__title {
+ color: $color--font-primary;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ &__description {
+ color: $color--font-inactive;
+ margin-top: 5px;
+ font-size: small;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+}
+
+@media only screen and (max-width: 600px) {
+ .og-preview {
+ grid-template-columns: 1fr;
+
+ &__details {
+ padding: 5px 10px;
+ }
+ }
+}
+
+.dropdown {
+ position: relative;
+ width: min-content;
+
+ &__content {
+ position: absolute;
+ right: 0;
+ left: auto;
+ z-index: 10;
+ display: flex;
+ flex-direction: column;
+ margin-top: 4px;
+ list-style: none;
+ background: white;
+ border: thin solid $color--action-menu-border;
+ border-radius: 5px;
+ box-shadow: 2px 2px 0 $color--action-menu-shadow;
+ overflow: hidden;
+ min-width: 200px;
+ max-width: 90vw;
+ }
+
+ &__header {
+ padding: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ }
+
+ &__title {
+ margin: 0;
+ font-weight: 500;
+ font-size: 16px;
+ }
+
+ &__close-button {
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: transparent;
+ height: 20px;
+ width: 20px;
+ cursor: pointer;
+ }
+
+ &__items {
+ padding: 0;
+ list-style: none;
+ width: max-content;
+ display: flex;
+ flex-direction: column;
+
+ li {
+ padding: 6px 10px;
+ transition: background-color 0.2s ease-out;
+ cursor: pointer;
+
+ &:not(:first-child) {
+ border-top: thin solid $color--table-border;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: $color--tile-background-hover;
+ }
+ }
+ }
+}
+
+.user-avatar {
+ padding: 10px;
+ min-width: 200px;
+ max-width: 80vw;
+ max-height: 80vh;
+
+ hr {
+ margin: 10px 0;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ img.user-profile {
+ width: 50px;
+ height: 50px;
+ border-radius: 100%;
+ border: 1.5px solid white;
+ transition: all .4s ease-in-out;
+ }
+
+ .user-info {
+ flex: 1;
+
+ .user-name {
+ text-wrap: auto;
+ font-size: 14px;
+ margin-top: 0;
+ margin-bottom: 5px;
+ font-weight: bold;
+ color: $color--font-primary;
+ }
+
+ p {
+ font-size: smaller;
+ margin: 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-wrap: auto;
+ }
+ }
+ }
+ &__actions {
+ list-style: none;
+ padding: 0;
+
+ .action-item {
+ cursor: pointer;
+ background: none;
+ border: none;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ width: fit-content;
+ padding: 5px 10px;
+ }
+
+ li {
+ transition: background-color 0.2s ease-out;
+
+ &:hover,
+ &:focus {
+ background-color: $color--tile-background-hover;
+ }
+ }
+ }
+}
+/*
+ vue Transition:fade-down --start--
+*/
+.fade-down-enter-active {
+ transition: all 0.2s ease-out;
+}
+
+.fade-down-leave-active {
+ transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
+}
+
+.fade-down-enter-from,
+.fade-down-leave-to {
+ transform: translateY(-20px);
+ opacity: 0;
+}
+/*
+ vue Transition:fade-down --end--
+*/
+
diff --git a/resources/vue/apps/forum/categories/Edit.vue b/resources/vue/apps/forum/categories/Edit.vue
new file mode 100644
index 0000000..b3ca722
--- /dev/null
+++ b/resources/vue/apps/forum/categories/Edit.vue
@@ -0,0 +1,92 @@
+<script setup>
+import {computed, onMounted, reactive, useTemplateRef} from "vue";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+
+const CSRF = STUDIP.CSRF_TOKEN;
+
+const props = defineProps({
+ category: {
+ type: Object
+ }
+});
+
+const categoryForm = reactive({
+ ...props.category
+});
+
+const formActionURL = computed(() => {
+ if (props.category.category_id) {
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/categories/save/${props.category.category_id}`);
+ }
+
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/categories/save`);
+});
+
+const nameInput = useTemplateRef('name-input');
+
+onMounted(() => {
+ nameInput.value.focus();
+});
+</script>
+
+<template>
+ <div class="forum" style="display: flex;">
+ <form
+ class="default use-utility-classes forum-form"
+ :action="formActionURL"
+ method="post"
+ >
+ <input type="hidden" :name="CSRF.name" :value="CSRF.value">
+ <fieldset>
+ <legend v-if="category.category_id" class="hide-in-dialog">
+ {{ $gettext('Kategorie bearbeiten') }}
+ </legend>
+ <legend v-else class="hide-in-dialog">
+ {{ $gettext('Neue Kategorie anlegen') }}
+ </legend>
+
+ <section>
+ <label class="studiprequired m-0">
+ <span class="textlabel">{{ $gettext('Name') }}</span>
+ <span :title="$gettext('Name ist ein Pflichtfeld')" aria-hidden="true" class="asterisk">*</span>
+ <input
+ required
+ type="text"
+ name="name"
+ ref="name-input"
+ v-model="categoryForm.name"
+ class="max-w-full" />
+ </label>
+ </section>
+
+ <section>
+ <label>
+ {{ $gettext('Beschreibung') }}
+ <textarea rows="5" name="description" v-model="categoryForm.description"></textarea>
+ </label>
+ </section>
+
+ <section>
+ <label class="m-0">
+ <span class="required">
+ {{ $gettext('Farbe') }}
+ </span>
+ <input
+ type="color"
+ name="color"
+ v-model="categoryForm.color" />
+ </label>
+ </section>
+ </fieldset>
+ <footer data-dialog-button>
+ <button class="button accept">
+ {{ $gettext('Speichern') }}
+ </button>
+ <button class="button cancel" type="button" data-dialog-close>
+ {{ $gettext('Abbrechen') }}
+ </button>
+ </footer>
+ </form>
+ </div>
+</template>
+
diff --git a/resources/vue/apps/forum/categories/Index.vue b/resources/vue/apps/forum/categories/Index.vue
new file mode 100644
index 0000000..4d7be3e
--- /dev/null
+++ b/resources/vue/apps/forum/categories/Index.vue
@@ -0,0 +1,270 @@
+<script setup>
+import {computed, onMounted, ref} from "vue";
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import draggable from "vuedraggable";
+import { default as CreateCategory } from "@/vue/components/forum/categories/Create.vue";
+import CategoryItem from "@/vue/components/forum/categories/CategoryItem.vue";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import StudipPagination from "../../../components/StudipPagination.vue";
+import {useSortable} from "../../../composables/useSortable";
+
+const forumConfig = useForumConfig();
+const categories = ref([]);
+const pagination = ref({});
+
+const {
+ sortedData: sortedCategories,
+ sortBy,
+ getSortClass,
+ getAriaSortString,
+ getAriaSortLabel
+} = useSortable(categories);
+
+const toggleLayoutMessage = computed(() => {
+ if (forumConfig.tileLayout) {
+ return $gettext('Kachelansicht aktiviert');
+ }
+
+ return $gettext('Tabellarische Ansicht aktiviert');
+});
+
+const fetchCategories = async (offset = 0) => {
+ try {
+ const response = await STUDIP.jsonapi.withPromises().GET(
+ `courses/${STUDIP.URLHelper.parameters.cid}/forum-categories`,
+ {
+ data: { page: { offset } }
+ }
+ );
+
+ pagination.value = {
+ ...response.meta.page,
+ links: response.links
+ };
+
+ categories.value = await deserializeJSONAPIResponse(response);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+}
+
+const updateCategoriesOrder = async () => {
+ try {
+ const category_ids = categories.value.map(({ id }) => id);
+
+ const data = {
+ attributes: {
+ 'category-ids': category_ids
+ },
+ relationships: {
+ range: {
+ data: {
+ type: 'courses',
+ id: STUDIP.URLHelper.parameters.cid
+ }
+ }
+ }
+ };
+
+ await STUDIP.jsonapi.withPromises().PATCH(
+ `forum-categories/sort`,
+ { data: { data } }
+ );
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+}
+
+onMounted(async () => {
+ await fetchCategories();
+});
+</script>
+
+<template>
+ <ForumApp class="use-utility-classes">
+ <header class="header">
+ <div class="header__content header__content--with-actions">
+ <div>
+ <h2>
+ {{ $gettext('Kategorien') }}
+ </h2>
+ </div>
+
+ <div class="actions">
+ <CreateCategory v-if="forumConfig.isModerator" />
+ <button
+ v-if="forumConfig.tileLayout"
+ @click="forumConfig.toggleForumLayout()"
+ type="button"
+ :title="$gettext('Tabellarische Ansicht')" class="icon-button">
+ <StudipIcon shape="view-list" :size="20" />
+ </button>
+ <button
+ v-else
+ @click="forumConfig.toggleForumLayout()"
+ type="button"
+ :title="$gettext('Kachelansicht')" class="icon-button">
+ <StudipIcon shape="view-wall" :size="20" />
+ </button>
+ <div aria-live="polite" class="sr-only" role="status">{{ toggleLayoutMessage }}</div>
+ </div>
+ </div>
+ </header>
+ <div class="py-10">
+ <div v-if="forumConfig.tileLayout">
+ <draggable
+ v-if="sortedCategories.length"
+ v-model="sortedCategories"
+ item-key="category_id"
+ :animation="200"
+ @end="updateCategoriesOrder"
+ :disabled="!forumConfig.isModerator"
+ class="topic-cards-container"
+ :class="{
+ '--fill-free-space': sortedCategories.length > 1
+ }"
+ tag="ul">
+ <template #item="{element}">
+ <li>
+ <CategoryItem :category="element" />
+ </li>
+ </template>
+ <template v-if="forumConfig.isModerator" #footer>
+ <li key="footer">
+ <div class="topic-card --new-topic">
+ <CreateCategory
+ class="--with-label"
+ :label="$gettext('Neue Kategorie anlegen')"
+ />
+ </div>
+ </li>
+ </template>
+ </draggable>
+ <div v-else-if="forumConfig.isModerator" class="topic-cards-container">
+ <div class="topic-card --new-topic">
+ <CreateCategory
+ v-if="forumConfig.isModerator"
+ class="--with-label"
+ :label="$gettext('Neue Kategorie anlegen')"
+ />
+ </div>
+ </div>
+ </div>
+ <table v-else class="default forum-table --topics-index">
+ <colgroup>
+ <col>
+ <col style="width: 15%;">
+ <col style="width: 15%;">
+ <col style="width: 15%;">
+ <col style="width: 10%;">
+ <col style="width: 5%">
+ </colgroup>
+ <thead>
+ <tr class="sortable">
+ <th
+ :class="getSortClass('name')"
+ :aria-sort="getAriaSortString('name')"
+ :aria-label="getAriaSortLabel('name', $gettext('Name'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('name')"
+ :title="$gettext('Nach Name sortieren')">
+ {{ $gettext('Name') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.discussions_count')"
+ :aria-sort="getAriaSortString('meta.discussions_count')"
+ :aria-label="getAriaSortLabel('meta.discussions_count', $gettext('Anzahl der Diskussionen'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.discussions_count')"
+ :title="$gettext('Nach Anzahl der Diskussionen sortieren')">
+ {{ $gettext('Diskussionen') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.users_count')"
+ :aria-sort="getAriaSortString('meta.users_count')"
+ :aria-label="getAriaSortLabel('meta.users_count', $gettext('Anzahl der Teilnehmenden'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.users_count')"
+ :title="$gettext('Nach Anzahl der Teilnehmenden sortieren')">
+ {{ $gettext('Teilnehmende') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.postings_count')"
+ :aria-sort="getAriaSortString('meta.postings_count')"
+ :aria-label="getAriaSortLabel('meta.postings_count', $gettext('Anzahl der Beiträge'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.postings_count')"
+ :title="$gettext('Nach Anzahl der Beiträge sortieren')">
+ {{ $gettext('Beiträge') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.recent_activity')"
+ :aria-sort="getAriaSortString('meta.recent_activity')"
+ :aria-label="getAriaSortLabel('meta.recent_activity', $gettext('Aktivitäten'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.recent_activity')"
+ :title="$gettext('Nach Aktivitäten sortieren')">
+ {{ $gettext('Aktivitäten') }}
+ </a>
+ </th>
+ <th></th>
+ </tr>
+ </thead>
+ <draggable
+ v-model="sortedCategories"
+ item-key="category_id"
+ :animation="200"
+ v-if="sortedCategories.length"
+ @end="updateCategoriesOrder"
+ :disabled="!forumConfig.isModerator"
+ tag="tbody">
+ <template #item="{element}">
+ <CategoryItem :category="element" render-type="tr" />
+ </template>
+ </draggable>
+ <tbody v-else>
+ <tr>
+ <td colspan="6">
+ {{ $gettext('Keine Kategorien vorhanden.') }}
+ </td>
+ </tr>
+ </tbody>
+ <tfoot v-if="forumConfig.isModerator">
+ <tr class="new-topic">
+ <td colspan="6">
+ <div class="footer-actions-container">
+ <CreateCategory
+ class="--with-label"
+ :label="$gettext('Neue Kategorie anlegen')"
+ />
+ </div>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ <StudipPagination
+ v-if="pagination.total > pagination.limit"
+ :currentOffset="pagination.offset"
+ :totalItems="pagination.total"
+ :itemsPerPage="pagination.limit"
+ @updateOffset="fetchCategories" />
+ </div>
+ </ForumApp>
+</template>
diff --git a/resources/vue/apps/forum/categories/Show.vue b/resources/vue/apps/forum/categories/Show.vue
new file mode 100644
index 0000000..c9a411d
--- /dev/null
+++ b/resources/vue/apps/forum/categories/Show.vue
@@ -0,0 +1,125 @@
+<script setup>
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import StudipDateTime from "../../../components/StudipDateTime.vue";
+import TopicsIndex from "@/vue/components/forum/topics/TopicsIndex.vue";
+import CreateTopic from "@/vue/components/forum/topics/CreateTopic.vue";
+import {computed, onMounted, ref} from "vue";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import StudipPagination from "../../../components/StudipPagination.vue";
+
+const forumConfig = useForumConfig();
+
+const props = defineProps({
+ category: {
+ type: Object,
+ required: true
+ },
+ metadata: {
+ type: Object,
+ required: true
+ }
+});
+
+const topics = ref([]);
+const isLoading = ref(false);
+const pagination = ref({});
+
+const toggleLayoutMessage = computed(() => {
+ if (forumConfig.tileLayout) {
+ return $gettext('Kachelansicht aktiviert');
+ }
+
+ return $gettext('Tabellarische Ansicht aktiviert');
+});
+const fetchTopics = async (offset = 0) => {
+ try {
+ isLoading.value = true;
+
+ const response = await STUDIP.jsonapi.withPromises().GET(
+ `forum-categories/${props.category.category_id}/topics`,
+ { data: { page: { offset } } }
+ );
+
+ pagination.value = {
+ ...response.meta.page,
+ links: response.links
+ };
+
+ topics.value = await deserializeJSONAPIResponse(response);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+
+onMounted(async () => {
+ await fetchTopics();
+})
+</script>
+
+<template>
+ <ForumApp class="use-utility-classes forum">
+ <header class="header">
+ <div v-if="category.color" class="flag" :style="{ backgroundColor: category.color}"></div>
+ <div class="header__content header__content--with-actions items-start">
+ <div>
+ <h2>
+ {{ category.name }}
+ </h2>
+ <div class="mt-10 inline-flex gap-20 items-center">
+ <span class="inline-flex gap-5 items-center" :title="$gettext('Anzahl der Teilnehmenden an der Diskussion')" :aria-label="$gettext('Anzahl der Teilnehmenden an der Diskussion')" role="group">
+ <StudipIcon shape="community2" :size="15" role="info" aria-hidden="true" />
+ <small>{{ metadata.users_count }}</small>
+ </span>
+ <span class="inline-flex gap-5 items-center" :title="$gettext('Anzahl der Beiträge')" :aria-label="$gettext('Anzahl der Beiträge')" role="group">
+ <StudipIcon shape="forum" :size="15" role="info" aria-hidden="true" />
+ <small>{{ metadata.postings_count }}</small>
+ </span>
+ <span class="inline-flex gap-5 items-center" :title="$gettext('Letzte Aktivität')" :aria-label="$gettext('Letzte Aktivität')" role="group">
+ <StudipIcon shape="activity" :size="15" role="info" aria-hidden="true" />
+ <StudipDateTime v-if="metadata.recent_activity" :iso="metadata.recent_activity" :relative="true" />
+ <template v-else>{{ $gettext('Keine Aktivität') }}</template>
+ </span>
+ </div>
+ </div>
+
+ <div class="actions">
+ <CreateTopic :category_id="category.category_id" />
+ <button
+ v-if="forumConfig.tileLayout"
+ @click="forumConfig.toggleForumLayout()"
+ type="button"
+ :title="$gettext('Tabellarische Ansicht')"
+ class="icon-button">
+ <StudipIcon shape="view-list" :size="20" />
+ </button>
+ <button
+ v-else
+ @click="forumConfig.toggleForumLayout()"
+ type="button"
+ :title="$gettext('Kachelansicht')"
+ class="icon-button">
+ <StudipIcon shape="view-wall" :size="20" />
+ </button>
+ <div aria-live="polite" class="sr-only" role="status">{{ toggleLayoutMessage }}</div>
+ </div>
+ </div>
+ </header>
+ <div class="py-10">
+ <TopicsIndex :topics="topics" :isLoading="isLoading" :categoryId="category.category_id">
+ <template #pagination>
+ <StudipPagination
+ v-if="pagination.total > pagination.limit"
+ :currentOffset="pagination.offset"
+ :totalItems="pagination.total"
+ :itemsPerPage="pagination.limit"
+ @updateOffset="fetchTopics" />
+ </template>
+ </TopicsIndex>
+ </div>
+ </ForumApp>
+</template>
diff --git a/resources/vue/apps/forum/discussions/Edit.vue b/resources/vue/apps/forum/discussions/Edit.vue
new file mode 100644
index 0000000..4531ce1
--- /dev/null
+++ b/resources/vue/apps/forum/discussions/Edit.vue
@@ -0,0 +1,164 @@
+<script setup>
+import {computed, onMounted, reactive, useTemplateRef} from "vue";
+import SelectTopicInput from "@/vue/components/forum/topics/SelectTopicInput.vue";
+import SelectDiscussionType from "@/vue/components/forum/discussions/SelectDiscussionType.vue";
+import SelectTagsInput from "@/vue/components/forum/SelectTagsInput.vue";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import StudipWysiwyg from "../../../components/StudipWysiwyg.vue";
+import StudipSwitch from "../../../components/StudipSwitch.vue";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+
+const CSRF = STUDIP.CSRF_TOKEN;
+
+const props = defineProps({
+ discussion: {
+ type: Object,
+ },
+ topics: {
+ type: Array,
+ required: true
+ },
+ discussion_types: {
+ type: Array,
+ required: true
+ },
+ tags: {
+ type: Array,
+ required: true
+ }
+});
+
+const discussionForm = reactive({
+ ...props.discussion,
+ closed_at: Boolean(props.discussion.closed_at),
+ sticky: Boolean(props.discussion.sticky),
+ topic: props.topics.find(({ topic_id }) => topic_id === props.discussion.topic_id),
+ type: props.discussion_types.find(({ type_id }) => type_id === parseInt(props.discussion.type_id))
+});
+
+const formActionURL = computed(() => {
+ if (props.discussion.discussion_id) {
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussions/save/${props.discussion.discussion_id}`);
+ }
+
+ return STUDIP.URLHelper.getURL('dispatch.php/course/forum/discussions/save');
+});
+
+const availableTags = computed(() => {
+ if (discussionForm.tags && discussionForm.tags.length > 0) {
+ const selectedTagsId = discussionForm.tags.map(({ id }) => id);
+ return props.tags.filter(({ id }) => selectedTagsId.indexOf(id) < 0);
+ }
+
+ return props.tags;
+});
+
+const titleInput = useTemplateRef('title-input');
+
+onMounted(() => {
+ titleInput.value.focus();
+});
+</script>
+
+<template>
+ <div class="forum" style="display: flex;">
+ <form
+ class="default use-utility-classes forum-form"
+ :action="formActionURL"
+ method="post"
+ >
+ <input type="hidden" :name="CSRF.name" :value="CSRF.value">
+ <fieldset>
+ <legend v-if="discussion.discussion_id" class="hide-in-dialog">
+ {{ $gettext('Diskussion bearbeiten') }}
+ </legend>
+ <legend v-else class="hide-in-dialog">
+ {{ $gettext('Neue Diskussion starten') }}
+ </legend>
+
+ <section>
+ <label class="studiprequired m-0">
+ <span class="textlabel">{{ $gettext('Diskussionstitel') }}</span>
+ <span :title="$gettext('Diskussionstitel ist ein Pflichtfeld')" aria-hidden="true" class="asterisk">*</span>
+ <input
+ required
+ type="text"
+ name="title"
+ ref="title-input"
+ v-model="discussionForm.title"
+ class="max-w-full" />
+ </label>
+
+ <div class="discussion-badges-container">
+ <div v-if="discussionForm.topic" class="badge">
+ <span :style="{ backgroundColor: discussionForm.topic.color ?? '#EDEDED', height: '12px', width: '12px'}"></span>
+ <span>{{ discussionForm.topic.name }}</span>
+ <button @click="discussionForm.topic = null" class="action">
+ <StudipIcon shape="decline" :size="15" />
+ </button>
+ </div>
+ <div v-if="discussionForm.type" class="badge">
+ <StudipIcon :shape="discussionForm.type.icon" :size="15" />
+ <span>{{ discussionForm.type.name }}</span>
+ <button @click="discussionForm.type = null" class="action">
+ <StudipIcon shape="decline" :size="15" />
+ </button>
+ </div>
+ <template v-for="tag in discussionForm.tags" :key="tag">
+ <div class="badge">
+ <span>{{ '#'+tag.name }}</span>
+ <button @click="discussionForm.tags = discussionForm.tags.filter(t => t.name !== tag.name)" class="action">
+ <StudipIcon shape="decline" :size="15" />
+ </button>
+ </div>
+ </template>
+ </div>
+ </section>
+
+ <section class="inputs-container">
+ <label class="flex-1">
+ <span class="sr-only">{{ $gettext('Thema') }}</span>
+ <input type="hidden" name="topic" :value="JSON.stringify(discussionForm.topic)">
+ <SelectTopicInput :options="topics" v-model="discussionForm.topic" :taggable="true" />
+ </label>
+ <label class="flex-1">
+ <span class="sr-only">{{ $gettext('Diskussionstyp') }}</span>
+ <input v-if="discussionForm.type" type="hidden" name="type_id" :value="discussionForm.type.type_id">
+ <SelectDiscussionType :options="discussion_types" v-model="discussionForm.type" />
+ </label>
+ <label class="flex-1">
+ <span class="sr-only">{{ $gettext('Schlagworte') }}</span>
+ <input type="hidden" name="tags" :value="JSON.stringify(discussionForm.tags)">
+ <SelectTagsInput :options="availableTags" v-model="discussionForm.tags" multiple :taggable="true" />
+ </label>
+ </section>
+
+ <section class="mt-10" v-if="!discussion.discussion_id">
+ <input type="hidden" name="content" v-model="discussionForm.content">
+ <StudipWysiwyg :required="true" v-model="discussionForm.content" />
+ </section>
+ <section class="mt-10">
+ <StudipSwitch name="closed_at" v-model="discussionForm.closed_at" :label="$gettext('Diskussion schließen')" />
+ </section>
+ <section class="mt-10">
+ <StudipSwitch name="sticky" v-model="discussionForm.sticky" :label="$gettext('Anpinnen')" />
+ </section>
+ </fieldset>
+ <footer data-dialog-button>
+ <button class="button accept">
+ {{ $gettext('Speichern') }}
+ </button>
+ <button class="button cancel" type="button" data-dialog-close>
+ {{ $gettext('Abbrechen') }}
+ </button>
+ </footer>
+ </form>
+ </div>
+</template>
+
+<style scoped>
+.vs__dropdown-menu {
+ z-index: 1020 !important;
+}
+</style>
+
diff --git a/resources/vue/apps/forum/discussions/Show.vue b/resources/vue/apps/forum/discussions/Show.vue
new file mode 100644
index 0000000..b41b909
--- /dev/null
+++ b/resources/vue/apps/forum/discussions/Show.vue
@@ -0,0 +1,325 @@
+<script setup>
+import {onMounted, computed, ref} from "vue";
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import ForumMembers from "@/vue/components/forum/ForumMembers.vue";
+import {numberFormatter} from "../../../../assets/javascripts/lib/number_formatter";
+import {useForumPost} from "../../../store/pinia/forum/ForumPost";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import Post from "@/vue/components/forum/posts/Post.vue";
+import PostCreateForm from "@/vue/components/forum/posts/PostCreateForm.vue";
+import Loader from "@/vue/components/forum/Loader.vue";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import StudipDateTime from "../../../components/StudipDateTime.vue";
+import SubscriptionDropdown from "@/vue/components/forum/SubscriptionDropdown.vue";
+import {highlightText, removeHighlight} from "@/vue/components/forum/helpers";
+import {getSearchURL, getTopicURL} from "@/vue/components/forum/helpers/urls";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+
+const forumConfig = useForumConfig();
+const forumPostStore = useForumPost();
+const props = defineProps({
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ category: {
+ type: Object,
+ required: true,
+ },
+ auth_user: {
+ type: Object,
+ required: true,
+ },
+ read_index: {
+ type: Number,
+ required: true,
+ default: 0
+ },
+ redirect: {
+ type: String,
+ default: 'topic'
+ },
+ search_keyword: {
+ type: String,
+ default: ''
+ }
+});
+
+const isLoading = ref(false);
+const postCreateForm = ref(false)
+
+const editDiscussion = id => STUDIP.Dialog.fromURL(
+ STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussions/edit/${id}`),
+ {
+ width: '900'
+ }
+);
+
+const posts = computed(() => forumPostStore.posts);
+
+const addPost = () => {
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
+ postCreateForm.value = false;
+}
+
+const goBackURL = computed(() => {
+ return props.redirect === 'search' ? getSearchURL() : getTopicURL(props.discussion.topic_id);
+});
+
+const fetchPostings = async () => {
+ let allPostings = [];
+ let offset = 0;
+ let total = null;
+
+ try {
+ let hasMore = true;
+
+ while (hasMore) {
+ const response = await STUDIP.jsonapi.withPromises().GET(
+ `forum-discussions/${props.discussion.discussion_id}/postings`,
+ {
+ data: {
+ include: 'author,opengraph-urls,posting,reactions,reactions.user&fields[users]=id',
+ page: { offset }
+ }
+ }
+ );
+
+ const deserializedPostings = await deserializeJSONAPIResponse(response);
+ allPostings.push(...deserializedPostings);
+
+ if (total === null) {
+ total = response.meta.page.total;
+ }
+
+ offset += response.meta.page.limit;
+ hasMore = offset < total;
+ }
+
+ forumPostStore.initPosts(allPostings);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+};
+
+
+
+onMounted(async () => {
+ isLoading.value = true;
+
+ await fetchPostings();
+
+ const urlHash = window.location.hash.split("#")[1];
+ if (urlHash) {
+ if (urlHash === 'new-post') {
+ postCreateForm.value = true;
+ }
+ document.getElementById(urlHash).scrollIntoView();
+ } else if (props.read_index < posts.value.length) {
+ document.querySelectorAll(".post")[props.read_index].scrollIntoView();
+ }
+
+ isLoading.value = false;
+
+ if (props.search_keyword !== "") {
+ highlightText(props.search_keyword, '.post-content');
+
+ document.querySelector('.post-content mark').scrollIntoView();
+
+ // remove highlights
+ document.getElementById("discussion_start").addEventListener("click", function() {
+ removeHighlight('.post-content mark');
+ })
+ }
+})
+</script>
+
+<template>
+ <ForumApp id="discussion_start">
+ <header class="header">
+ <div v-if="category.color" class="flag" :style="{ backgroundColor: category.color}"></div>
+ <div class="header__content header__content--with-actions items-start">
+ <div class="flex items-start gap-10">
+ <a :href="goBackURL" :title="$gettext('Zum Thema')" class="go-back-link">
+ <StudipIcon shape="arr_1left" :size="20" />
+ </a>
+ <div>
+ <ul class="breadcrumb">
+ <li>
+ <a :href="getTopicURL(discussion.topic_id)" :title="$gettext('Zum Thema')">
+ {{ discussion.topic.name }}
+ </a>
+ </li>
+ <li>
+ <div class="inline-flex items-start gap-5">
+ <StudipIcon class="mt-1" v-if="discussion.sticky" role="info" shape="pin" :size="20" />
+ {{ discussion.title }}
+ </div>
+ </li>
+ </ul>
+
+ <ul class="mt-10 tags-container">
+ <li v-if="discussion.type.name" class="tags-container__tag">
+ <StudipIcon role="info" :shape="discussion.type.icon" :size="15" :title="discussion.type.name"/>
+ </li>
+ <template v-for="tag in discussion.tags" :key="tag.id">
+ <li class="tags-container__tag">
+ <a :href="getSearchURL(`tag_ids[]=${tag.id}`)" :title="$gettext('Zum Schlagwort')" :aria-label="$gettext('Zum Schlagwort')">{{ '#'+tag.name }}</a>
+ </li>
+ </template>
+ </ul>
+ </div>
+ </div>
+
+ <div class="actions">
+ <div
+ v-if="discussion.closed_at"
+ :title="$gettext('Diskussion ist geschlossen')"
+ class="discussion-closed">
+ <em>
+ {{ $gettext('Geschlossen:') }}
+ <StudipDateTime :iso="discussion.closed_at" :relative="true" />
+ </em>
+ <StudipIcon shape="lock-locked2" :size="20" role="inactive" />
+ </div>
+ <button v-if="forumConfig.isModerator" @click="editDiscussion(discussion.discussion_id)" type="button" :title="$gettext('Diskussion bearbeiten')" class="icon-button">
+ <StudipIcon shape="edit" :size="20" />
+ </button>
+ <SubscriptionDropdown
+ v-if="!discussion.closed_at"
+ :subject="{
+ id: discussion.discussion_id,
+ type: 'forum-discussions'
+ }"
+ :user_subscription="auth_user.subscription"
+ />
+ </div>
+ </div>
+ </header>
+ <div class="discussion">
+ <template v-if="posts[0]">
+ <Post :post="posts[0]" :auth_user="auth_user" :discussion="discussion" :is_unread="read_index === 0" />
+ </template>
+ <div v-else class="discussion__body">
+ <Loader v-if="isLoading" />
+ <p v-else class="text-center">
+ {{ $gettext('Es sind noch keine Beiträge vorhanden.') }}
+ </p>
+ </div>
+ <hr />
+ <div class="discussion__status">
+ <div class="flex items-start gap-20">
+ <div class="text-center">
+ <p>{{ $gettext('Erstellt') }}</p>
+ <StudipDateTime :iso="discussion.mkdate" :date_only="true"/>
+ </div>
+ <div class="text-center">
+ <p>{{ $gettext('Beiträge') }}</p>
+ <p>{{ posts.length }}</p>
+ </div>
+ <div class="text-center">
+ <p>{{ $gettext('Aufrufe') }}</p>
+ <p>{{ numberFormatter(discussion.view_count, 1) }}</p>
+ </div>
+ <div class="text-center">
+ <p>{{ $gettext('Aktivität') }}</p>
+ <StudipDateTime v-if="posts[posts.length -1]" :iso="posts[posts.length -1].mkdate" :relative="true" />
+ <StudipDateTime v-else :iso="discussion.mkdate" :relative="true"/>
+ </div>
+ </div>
+ <ForumMembers :members="discussion.members" :limit="5" size="35px" />
+ <a
+ v-if="!discussion.closed_at"
+ href="#new-post"
+ class="button --with-icon m-0"
+ role="button"
+ :title="$gettext('Antworten')"
+ :aria-label="$gettext('Antworten')"
+ :class="{
+ 'disabled': postCreateForm
+ }"
+ @click="postCreateForm = true"
+ >
+ <StudipIcon shape="reply" :size="20" class="icon-default" aria-hidden="true" />
+ <StudipIcon shape="reply" :size="20" class="icon-hover" role="info_alt" aria-hidden="true" />
+ {{ $gettext('Antworten') }}
+ </a>
+ </div>
+ <hr />
+ </div>
+ <div class="posts-container">
+ <template v-for="(post, index) in posts.slice(1)" :key="post.id">
+ <Post
+ :post="post"
+ :auth_user="auth_user"
+ :discussion="discussion"
+ :is_unread="read_index < index + 2"
+ />
+ <hr v-if="index < posts.slice(1).length - 1" class="divider"/>
+ </template>
+ </div>
+
+ <div v-if="posts.length > 3" class="discussion">
+ <div class="discussion__status">
+ <div class="flex items-start gap-20">
+ <div class="text-center">
+ <p>{{ $gettext('Erstellt') }}</p>
+ <StudipDateTime :iso="discussion.mkdate" :date_only="true"/>
+ </div>
+ <div class="text-center">
+ <p>{{ $gettext('Beiträge') }}</p>
+ <p>{{ posts.length }}</p>
+ </div>
+ <div class="text-center">
+ <p>{{ $gettext('Aufrufe') }}</p>
+ <p>{{ numberFormatter(discussion.view_count, 1) }}</p>
+ </div>
+ <div class="text-center">
+ <p>{{ $gettext('Aktivität') }}</p>
+ <StudipDateTime v-if="posts[posts.length -1]" :iso="posts[posts.length -1].mkdate" :relative="true" />
+ <StudipDateTime v-else :iso="discussion.mkdate" :relative="true"/>
+ </div>
+ </div>
+ <ForumMembers :members="discussion.members" :limit="5" size="35px" />
+ <a
+ v-if="!discussion.closed_at"
+ href="#new-post"
+ class="button --with-icon m-0"
+ role="button"
+ :title="$gettext('Antworten')"
+ :aria-label="$gettext('Antworten')"
+ :class="{
+ 'disabled': postCreateForm
+ }"
+ @click="postCreateForm = true"
+ >
+ <StudipIcon shape="reply" :size="20" class="icon-default" aria-hidden="true" />
+ <StudipIcon shape="reply" :size="20" class="icon-hover" role="info_alt" aria-hidden="true" />
+ {{ $gettext('Antworten') }}
+ </a>
+ </div>
+ </div>
+
+ <div id="new-post" class="post-form-container">
+ <PostCreateForm
+ v-if="postCreateForm && !discussion.closed_at"
+ :discussion_id="discussion.discussion_id"
+ :auth_user="auth_user"
+ @canceled="postCreateForm = false"
+ @created="addPost"
+ />
+ </div>
+ </ForumApp>
+</template>
+
+<style>
+#content-wrapper {
+ overflow-x: unset !important;
+}
+
+#sidebar #sidebar-actions {
+ position: sticky;
+ top: 50px;
+}
+</style>
diff --git a/resources/vue/apps/forum/discussions_types/Edit.vue b/resources/vue/apps/forum/discussions_types/Edit.vue
new file mode 100644
index 0000000..df6fa28
--- /dev/null
+++ b/resources/vue/apps/forum/discussions_types/Edit.vue
@@ -0,0 +1,91 @@
+<script setup>
+import {computed, reactive} from "vue";
+import StudipIcon from "../../../components/StudipIcon.vue";
+
+const CSRF = STUDIP.CSRF_TOKEN;
+
+const props = defineProps({
+ discussion_type: {
+ type: Object,
+ },
+ icons: {
+ type: Array,
+ required: true
+ }
+});
+
+const formSate = reactive({
+ ...props.discussion_type
+});
+
+const formActionURL = computed(() => {
+ if (props.discussion_type.type_id) {
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussion_types/save/${props.discussion_type.type_id}`);
+ }
+
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussion_types/save`);
+});
+</script>
+
+<template>
+ <form
+ class="default forum"
+ :action="formActionURL"
+ method="post"
+ >
+ <input type="hidden" :name="CSRF.name" :value="CSRF.value">
+ <fieldset>
+ <legend class="hide-in-dialog">
+ {{ $gettext('Neuen Diskussionstyp anlegen') }}
+ </legend>
+
+ <section>
+ <label>
+ <span class="required">
+ {{ $gettext('Name') }}
+ </span>
+ <input
+ required
+ type="text"
+ name="name"
+ v-model="formSate.name"
+ maxlength="100" />
+ </label>
+ </section>
+
+ <section>
+ <label for="studip_icons">
+ <span class="required">
+ {{ $gettext('Icon') }}
+ </span>
+ </label>
+ <div id="studip_icons" class="studip-icons-container">
+ <input type="hidden" v-model="formSate.icon" name="icon" required />
+
+ <template v-for="icon in icons" :key="icon">
+ <button
+ class="icon"
+ type="button"
+ :title="icon"
+ :class="{
+ 'disabled': formSate.icon && formSate.icon !== icon,
+ 'active': formSate.icon === icon
+ }"
+ @click="formSate.icon = icon">
+ <StudipIcon :shape="icon" :size="40" />
+ </button>
+ </template>
+ </div>
+ </section>
+ </fieldset>
+ <footer data-dialog-button>
+ <button :disabled="!formSate.icon || !formSate.name" class="button accept">
+ {{ $gettext('Speichern') }}
+ </button>
+ <button class="button cancel" type="button" data-dialog-close>
+ {{ $gettext('Abbrechen') }}
+ </button>
+ </footer>
+ </form>
+</template>
+
diff --git a/resources/vue/apps/forum/recent/Index.vue b/resources/vue/apps/forum/recent/Index.vue
new file mode 100644
index 0000000..5b8a788
--- /dev/null
+++ b/resources/vue/apps/forum/recent/Index.vue
@@ -0,0 +1,71 @@
+<script setup>
+import {onMounted, ref} from "vue";
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import DiscussionIndex from "@/vue/components/forum/discussions/DiscussionIndex.vue";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import StudipPagination from "../../../components/StudipPagination.vue";
+
+const props = defineProps({
+ last_visit: {
+ type: Number,
+ required: true
+ }
+});
+
+const discussions = ref([]);
+const pagination = ref({});
+const isLoading = ref(false);
+
+const fetchDiscussions = async (offset = 0) => {
+ try {
+ isLoading.value = true;
+ const response = await STUDIP.jsonapi.withPromises().GET(
+ `courses/${STUDIP.URLHelper.parameters.cid}/forum-discussions`,
+ {
+ data: {
+ include: 'category,discussion-type,members,tags',
+ filter: {
+ 'last-visit': props.last_visit
+ },
+ page: { offset }
+ }
+ }
+ );
+
+ pagination.value = {
+ ...response.meta.page,
+ links: response.links
+ };
+
+ discussions.value = await deserializeJSONAPIResponse(response)
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+
+onMounted(async () => {
+ await fetchDiscussions();
+});
+</script>
+
+<template>
+ <ForumApp class="use-utility-classes">
+ <DiscussionIndex :discussions="discussions" :withActions="false" :isLoading="isLoading">
+ <template #pagination>
+ <tfoot v-if="pagination && pagination.total > pagination.limit">
+ <tr>
+ <td colspan="7">
+ <StudipPagination
+ :currentOffset="pagination.offset"
+ :totalItems="pagination.total"
+ :itemsPerPage="pagination.limit"
+ @updateOffset="fetchDiscussions" />
+ </td>
+ </tr>
+ </tfoot>
+ </template>
+ </DiscussionIndex>
+ </ForumApp>
+</template>
diff --git a/resources/vue/apps/forum/search/Index.vue b/resources/vue/apps/forum/search/Index.vue
new file mode 100644
index 0000000..9e7c36a
--- /dev/null
+++ b/resources/vue/apps/forum/search/Index.vue
@@ -0,0 +1,282 @@
+<script setup>
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import {computed, onMounted, reactive, ref} from "vue";
+import SelectTopicInput from "@/vue/components/forum/topics/SelectTopicInput.vue";
+import SelectTagsInput from "@/vue/components/forum/SelectTagsInput.vue";
+import SelectDiscussionType from "@/vue/components/forum/discussions/SelectDiscussionType.vue";
+import {getTopicURL} from "@/vue/components/forum/helpers/urls";
+import SelectUserInput from "@/vue/components/forum/SelectUserInput.vue";
+import DiscussionIndex from "@/vue/components/forum/discussions/DiscussionIndex.vue";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import StudipSelect from "../../../components/StudipSelect.vue";
+import {highlightText, removeHighlight} from "@/vue/components/forum/helpers";
+
+const discussionStatuses = [
+ {
+ value: 1,
+ label: $gettext('Alle')
+ },
+ {
+ value: 2,
+ label: $gettext('Geöffnet')
+ },
+ {
+ value: 3,
+ label: $gettext('Geschlossen')
+ }
+];
+
+const CSRF = STUDIP.CSRF_TOKEN;
+
+const props = defineProps({
+ search: {
+ type: Object,
+ required: true
+ },
+ discussions: {
+ type: Array,
+ required: true
+ },
+ topics: {
+ type: Array,
+ required: true
+ },
+ discussion_types: {
+ type: Array,
+ required: true
+ },
+ tags: {
+ type: Array,
+ required: true
+ },
+ course_members: {
+ type: Array,
+ required: true
+ }
+});
+
+const isFilterVisible = ref(true);
+
+const searchForm = reactive({
+ ...props.search,
+ begin: toDateString(props.search.begin),
+ end: toDateString(props.search.end),
+ discussion_status: discussionStatuses.find(status => status.value === props.search.discussion_status),
+ topics: props.topics.filter(({ topic_id }) => props.search.topic_ids.includes(topic_id)),
+ tags: props.tags.filter(({ id }) => props.search.tag_ids.includes(id.toString())),
+ types: props.discussion_types.filter(({ type_id }) => props.search.discussion_type_ids.includes(type_id.toString())),
+ authors: props.course_members.filter(({ user_id }) => props.search.user_ids.includes(user_id))
+});
+
+const availableTags = computed(() => {
+ if (searchForm.tags && searchForm.tags.length > 0) {
+ const selectedTagsId = searchForm.tags.map(({ id }) => id);
+ return props.tags.filter(({ id }) => selectedTagsId.indexOf(id) < 0);
+ }
+
+ return props.tags;
+});
+
+const availableTopics = computed(() => {
+ if (searchForm.topics && searchForm.topics.length > 0) {
+ const selectedTopicsId = searchForm.topics.map(({ topic_id }) => topic_id);
+ return props.topics.filter(({ topic_id }) => selectedTopicsId.indexOf(topic_id) < 0);
+ }
+
+ return props.topics;
+});
+
+const availableTypes = computed(() => {
+ if (searchForm.types && searchForm.types.length > 0) {
+ const selectedTypesId = searchForm.types.map(({ type_id }) => type_id);
+ return props.discussion_types.filter(({ type_id }) => selectedTypesId.indexOf(type_id) < 0);
+ }
+
+ return props.discussion_types;
+});
+
+const actionURL = STUDIP.URLHelper.getURL(`dispatch.php/course/forum/search`);
+
+const resetSearchForm = () => {
+ Object.assign(searchForm, {
+ keyword: '',
+ discussion_status: null,
+ begin: null,
+ end: null,
+ topics: [],
+ tags: [],
+ types: [],
+ authors: []
+ });
+}
+
+function toUnixTimestamp(date) {
+ return (new Date(date)).getTime() / 1000;
+}
+
+function toDateString(unixTimestamp) {
+ if (!unixTimestamp) {
+ return '';
+ }
+
+ return (new Date(parseInt(unixTimestamp) * 1000)).toISOString().split('T')[0];
+}
+
+onMounted(() => {
+ if(searchForm.keyword.length > 1 && props.discussions.length) {
+ highlightText(searchForm.keyword, '.title');
+
+ // remove highlights
+ document.getElementById("forum-search").addEventListener("click", function() {
+ removeHighlight('.title mark');
+ });
+ }
+})
+</script>
+
+<template>
+ <ForumApp id="forum-search">
+ <form :action="actionURL" method="post" class="default search-container use-utility-classes">
+ <input type="hidden" :name="CSRF.name" :value="CSRF.value">
+ <h1>{{ $gettext('Suche') }}</h1>
+ <div class="search-controls">
+ <div class="search-input-container">
+ <input name="keyword" type="text" :value="searchForm.keyword" :placeholder="$gettext('Diskussionen oder Beiträge')"/>
+ </div>
+ <button
+ type="submit"
+ class="button m-0 --with-icon"
+ :title="$gettext('Suchen')"
+ >
+ <StudipIcon shape="search" :size="20" class="icon-default" aria-hidden="true" />
+ <StudipIcon shape="search" :size="20" class="icon-hover" role="info_alt" aria-hidden="true" />
+ {{ $gettext('Suchen') }}
+ </button>
+ <button @click="resetSearchForm" type="button" class="icon-button" :title="$gettext('Zurücksetzen')">
+ <StudipIcon shape="decline" :size="20" />
+ </button>
+ </div>
+
+ <div class="filter-summary-container">
+ <template v-for="topic in searchForm.topics" :key="topic.topic_id">
+ <div class="badge">
+ <a :href="getTopicURL(topic.topic_id)" :title="$gettext('Zum Thema')" target="_blank" class="flex gap-5 items-center">
+ <span :style="{ backgroundColor: topic.color ?? '#EDEDED', height: '14px', width: '14px'}"></span>
+ {{ topic.name }}
+ </a>
+ <button @click="searchForm.topics = searchForm.topics.filter(t => t.topic_id !== topic.topic_id)" class="action">
+ <StudipIcon shape="decline" :size="15" />
+ </button>
+ </div>
+ </template>
+ <template v-for="type in searchForm.types" :key="type.type_id">
+ <div class="badge" :title="type.name">
+ <StudipIcon :shape="type.icon" :size="15" />
+ <span>{{ type.name }}</span>
+ <button @click="searchForm.types = searchForm.types.filter(t => t.type_id !== type.type_id)" class="action">
+ <StudipIcon shape="decline" :size="15" />
+ </button>
+ </div>
+ </template>
+ <template v-for="tag in searchForm.tags" :key="tag">
+ <div class="badge" :title="tag.name">
+ <span>{{ '#'+tag.name }}</span>
+ <button @click="searchForm.tags = searchForm.tags.filter(t => t.name !== tag.name)" class="action">
+ <StudipIcon shape="decline" :size="15" />
+ </button>
+ </div>
+ </template>
+
+ <template v-for="user in searchForm.authors" :key="user.user_id">
+ <div class="badge">
+ <a :href="user.profile_url" target="_blank" :title="$gettext('Zum Nutzer Profile')" class="flex gap-5 items-center">
+ <img width="15px" height="15px" :src="user.avatar_url" :alt="user.name" />
+ {{ user.name }}
+ </a>
+ <button @click="searchForm.authors = searchForm.authors.filter(u => u.name !== user.name)" class="action">
+ <StudipIcon shape="decline" :size="15" />
+ </button>
+ </div>
+ </template>
+ </div>
+
+ <hr />
+
+ <div>
+ <button
+ @click="isFilterVisible = !isFilterVisible"
+ type="button" class="toggle-filter-button"
+ :title="isFilterVisible ? $gettext('Erweiterte Filter zuklappen') : $gettext('Erweiterte Filter aufklappen')"
+ :aria-label="isFilterVisible ? $gettext('Erweiterte Filter zuklappen') : $gettext('Erweiterte Filter aufklappen')"
+ :aria-expanded="isFilterVisible.toString()"
+ >
+ {{ $gettext('Erweiterte Filter') }}
+ <StudipIcon v-if="isFilterVisible" shape="arr_1up" :size="20" />
+ <StudipIcon v-else shape="arr_1down" :size="20" />
+ </button>
+ <div v-if="isFilterVisible" class="filter-controls">
+ <label>
+ <span class="sr-only">{{ $gettext('Thema') }}</span>
+ <template v-for="topic in searchForm.topics" :key="topic.topic_id">
+ <input type="hidden" name="topic_ids[]" :value="topic.topic_id">
+ </template>
+ <SelectTopicInput id="" :options="availableTopics" v-model="searchForm.topics" multiple />
+ </label>
+ <label>
+ <span class="sr-only">{{ $gettext('Diskussionstyp') }}</span>
+ <template v-for="type in searchForm.types" :key="type.type_id">
+ <input type="hidden" name="discussion_type_ids[]" :value="type.type_id">
+ </template>
+ <SelectDiscussionType :options="availableTypes" v-model="searchForm.types" multiple />
+ </label>
+ <label>
+ <span class="sr-only">{{ $gettext('Schlagworte') }}</span>
+ <template v-for="tag in searchForm.tags" :key="tag.id">
+ <input type="hidden" name="tag_ids[]" :value="tag.id">
+ </template>
+ <SelectTagsInput :options="availableTags" v-model="searchForm.tags" multiple />
+ </label>
+ <label>
+ <span class="sr-only">{{ $gettext('Status der Diskussion') }}</span>
+ <input v-if="searchForm.discussion_status" type="hidden" name="discussion_status" :value="searchForm.discussion_status.value">
+ <StudipSelect
+ :options="discussionStatuses"
+ :placeholder="$gettext('Status der Diskussion')"
+ v-model="searchForm.discussion_status"
+ >
+ <template #no-options>
+ <div>
+ {{ $gettext('Es gibt keine Diskussionsstatus.') }}
+ </div>
+ </template>
+ </StudipSelect>
+ </label>
+ <div class="date-inputs-container">
+ <input type="date" v-model="searchForm.begin" :placeholder="$gettext('Von')" :aria-label="$gettext('Von')" autocomplete="off" />
+ <input type="date" v-model="searchForm.end" :placeholder="$gettext('Bis')" :aria-label="$gettext('Bis')" autocomplete="off" />
+
+ <input type="hidden" name="begin" :value="toUnixTimestamp(searchForm.begin)" />
+ <input type="hidden" name="end" :value="toUnixTimestamp(searchForm.end)" />
+ </div>
+ <label>
+ <span class="sr-only">{{ $gettext('Autor/-in') }}</span>
+ <template v-for="user in searchForm.authors" :key="user.user_id">
+ <input type="hidden" name="user_ids[]" :value="user.user_id">
+ </template>
+ <SelectUserInput
+ :options="course_members"
+ multiple
+ v-model="searchForm.authors"
+ />
+ </label>
+ </div>
+ </div>
+ </form>
+
+ <div class="search-result-container">
+ <h2>{{ $gettext('Suchergebnisse') }}</h2>
+ <DiscussionIndex :discussions="discussions" :withActions="false" redirect="search" />
+ </div>
+ </ForumApp>
+</template>
diff --git a/resources/vue/apps/forum/subscriptions/Index.vue b/resources/vue/apps/forum/subscriptions/Index.vue
new file mode 100644
index 0000000..6ed2a84
--- /dev/null
+++ b/resources/vue/apps/forum/subscriptions/Index.vue
@@ -0,0 +1,233 @@
+<script setup>
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import {onMounted, ref} from "vue";
+import {getDiscussionURL, getTopicURL} from "@/vue/components/forum/helpers/urls";
+import {useSortable} from "../../../composables/useSortable";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import StudipDateTime from "../../../components/StudipDateTime.vue";
+import SubscriptionDropdown from "@/vue/components/forum/SubscriptionDropdown.vue";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import {subscriptionTransformer} from "../../../components/forum/helpers/transformers";
+import StudipPagination from "../../../components/StudipPagination.vue";
+import Loader from "../../../components/forum/Loader.vue";
+
+const subscriptions = ref([]);
+const pagination = ref({});
+const isLoading = ref(false);
+
+const removeSubscription = subscription_id => {
+ subscriptions.value = subscriptions.value.filter(({ id }) => id !== subscription_id);
+}
+
+const getSubjectLabel = type => {
+ switch (type) {
+ case 'forum-discussions':
+ return $gettext('Diskussion');
+ case 'forum-topics':
+ return $gettext('Thema');
+ default:
+ return $gettext('Unbekannt');
+ }
+}
+
+const getSubscriptionDropdownTitle = type => {
+ switch (type) {
+ case 'forum-discussions':
+ return $gettext('Diskussion abonnieren');
+ case 'forum-topics':
+ return $gettext('Thema abonnieren');
+ default:
+ return $gettext('Abonnieren');
+ }
+}
+
+const fetchSubscribedDiscussions = async (offset = 0) => {
+ try {
+ isLoading.value = true;
+
+ const response = await STUDIP.jsonapi.withPromises().GET(
+ `courses/${STUDIP.URLHelper.parameters.cid}/forum-subscriptions`,
+ {
+ data: { include: 'subject', page: { offset } }
+ }
+ );
+
+ pagination.value = {
+ ...response.meta.page,
+ links: response.links
+ };
+
+ const data = await deserializeJSONAPIResponse(response)
+
+ subscriptions.value = data.map(subscriptionTransformer);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+
+const {
+ sortedData,
+ sortBy,
+ getSortClass,
+ getAriaSortString,
+ getAriaSortLabel
+} = useSortable(subscriptions);
+
+onMounted(async () => {
+ await fetchSubscribedDiscussions();
+});
+</script>
+
+<template>
+ <ForumApp class="use-utility-classes">
+ <header class="header">
+ <div class="header__content header__content--with-actions">
+ <div class="actions">
+ <h2>
+ {{ $gettext('Abonnent:innen') }}
+ </h2>
+ </div>
+
+ <div class="actions">
+
+ </div>
+ </div>
+ </header>
+ <div class="py-10">
+ <table class="default forum-table --subscription-index">
+ <colgroup>
+ <col>
+ <col style="width: 5%">
+ <col style="width: 20%">
+ <col style="width: 15%">
+ <col style="width: 15%">
+ </colgroup>
+ <thead>
+ <tr class="sortable">
+ <th
+ :class="getSortClass('subject.name')"
+ :aria-sort="getAriaSortString('subject.name')"
+ :aria-label="getAriaSortLabel('subject.name', $gettext('Thema Name'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('subject.name')"
+ :title="$gettext('Nach Thema Name sortieren')">
+ {{ $gettext('Thema') }}
+ </a>
+ </th>
+ <th></th>
+ <th
+ :class="getSortClass('subject.type')"
+ :aria-sort="getAriaSortString('subject.type')"
+ :aria-label="getAriaSortLabel('subject.type', $gettext('Typ'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('subject.type')"
+ :title="$gettext('Nach Typ sortieren')">
+ {{ $gettext('Typ') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('mkdate')"
+ :aria-sort="getAriaSortString('mkdate')"
+ :aria-label="getAriaSortLabel('mkdate', $gettext('Abonniert datum'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('mkdate')"
+ :title="$gettext('Nach Abonniert am sortieren')">
+ {{ $gettext('Abonniert am') }}
+ </a>
+ </th>
+ <th
+ class="actions"
+ :class="getSortClass('notification_type')"
+ :aria-sort="getAriaSortString('notification_type')"
+ :aria-label="getAriaSortLabel('notification_type', $gettext('Typ des Abonnements'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('notification_type')"
+ :title="$gettext('Nach Typ des Abonnements sortieren')">
+ {{ $gettext('Typ des Abonnements') }}
+ </a>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-if="isLoading" >
+ <td colspan="5">
+ <Loader />
+ </td>
+ </tr>
+ <tr v-else v-for="subscription in sortedData" :key="subscription.id">
+ <td>
+ <div class="discussion-overview">
+ <div class="title-with-actions">
+ <div class="title-with-actions__content">
+ <a v-if="subscription.subject.type === 'forum-topics'" :href="getTopicURL(subscription.subject.id)" :title="$gettext('Zum Thema')">
+ <h3 class="line-clamp-2">{{ subscription.subject.name }}</h3>
+ </a>
+ <a v-else-if="subscription.subject.type === 'forum-discussions'" :href="getDiscussionURL(subscription.subject.id)" :title="$gettext('Zur Diskussion')">
+ <h3 class="line-clamp-2">{{ subscription.subject.title }}</h3>
+ </a>
+ </div>
+
+ <div class="title-with-actions__actions-xs">
+ <SubscriptionDropdown
+ :title="getSubscriptionDropdownTitle(subscription.subject.type)"
+ :subject="subscription.subject"
+ :subject_id="subscription.subject_id"
+ :user_subscription="subscription"
+ @deleted="removeSubscription(subscription.id)"
+ />
+ </div>
+ </div>
+ </div>
+ </td>
+ <td>
+ <StudipIcon
+ v-if="subscription.subject.type === 'forum-discussion' && subscription.subject.closed_at"
+ :title="$gettext('Diskussion ist geschlossen')"
+ shape="lock-locked2"
+ :size="20"
+ role="inactive" />
+ </td>
+ <td>
+ {{ getSubjectLabel(subscription.subject.type) }}
+ </td>
+ <td>
+ <StudipDateTime :iso="subscription.mkdate" :relative="true" />
+ </td>
+ <td class="actions">
+ <div class="inline-flex">
+ <SubscriptionDropdown
+ :title="getSubscriptionDropdownTitle(subscription.subject.type)"
+ :subject="subscription.subject"
+ :user_subscription="subscription"
+ @deleted="removeSubscription(subscription.id)"
+ />
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot v-if="pagination.total > pagination.limit">
+ <tr>
+ <td colspan="5">
+ <StudipPagination
+ :currentOffset="pagination.offset"
+ :totalItems="pagination.total"
+ :itemsPerPage="pagination.limit"
+ @updateOffset="fetchSubscribedDiscussions" />
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </ForumApp>
+</template>
diff --git a/resources/vue/apps/forum/topics/Edit.vue b/resources/vue/apps/forum/topics/Edit.vue
new file mode 100644
index 0000000..845e062
--- /dev/null
+++ b/resources/vue/apps/forum/topics/Edit.vue
@@ -0,0 +1,125 @@
+<script setup>
+import {computed, onMounted, reactive, useTemplateRef} from "vue";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import StudipSelect from "../../../components/StudipSelect.vue";
+
+const CSRF = STUDIP.CSRF_TOKEN;
+
+const props = defineProps({
+ topic: {
+ type: Object,
+ },
+ categories: {
+ type: Array,
+ required: true
+ }
+});
+
+const topicForm = reactive({
+ ...props.topic,
+ category: props.categories.find(({ category_id }) => category_id === props.topic.category_id)
+});
+
+const formActionURL = computed(() => {
+ if (props.topic.topic_id) {
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/topics/save/${props.topic.topic_id}`);
+ }
+
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/topics/save`);
+});
+
+const nameInput = useTemplateRef('name-input');
+
+onMounted(() => {
+ nameInput.value.focus();
+});
+</script>
+
+<template>
+ <div class="forum" style="display: flex;">
+ <form
+ class="default use-utility-classes forum-form"
+ :action="formActionURL"
+ method="post"
+ >
+ <input type="hidden" :name="CSRF.name" :value="CSRF.value">
+ <fieldset>
+ <legend v-if="topic.topic_id" class="hide-in-dialog">
+ {{ $gettext('Thema bearbeiten') }}
+ </legend>
+ <legend v-else class="hide-in-dialog">
+ {{ $gettext('Neues Thema anlegen') }}
+ </legend>
+
+ <section>
+ <label class="studiprequired m-0">
+ <span class="textlabel">{{ $gettext('Name') }}</span>
+ <span :title="$gettext('Name ist ein Pflichtfeld')" aria-hidden="true" class="asterisk">*</span>
+ <input
+ required
+ type="text"
+ name="name"
+ ref="name-input"
+ v-model="topicForm.name"
+ class="max-w-full" />
+ </label>
+ </section>
+
+ <section>
+ <label>
+ {{ $gettext('Beschreibung') }}
+ <textarea rows="5" name="description" v-model="topicForm.description"></textarea>
+ </label>
+ </section>
+
+ <section>
+ <input type="hidden" name="category" :value="JSON.stringify(topicForm.category)">
+ <label for="category_input">
+ {{ $gettext('Kategorie') }}
+ <StudipSelect
+ id="category_input"
+ label="name"
+ :options="categories"
+ v-model="topicForm.category"
+ :reduce="(category) => {
+ if(category.name) {
+ return category;
+ }
+
+ return { name: category };
+ }"
+ :taggable="true"
+ >
+ <template #selected-option="{name, color}">
+ <div class="flex items-center">
+ <span v-if="color" :style="{ backgroundColor: color, height: '14px', width: '14px', marginRight: '8px'}"></span>
+ <span class="line-clamp-1 flex-1">{{name}}</span>
+ </div>
+ </template>
+ <template #option="{name, color}">
+ <div :style="{ display: 'flex', alignItems: 'center' }">
+ <span v-if="color" :style="{ backgroundColor: color, height: '14px', width: '14px', marginRight: '8px'}"></span>
+ <span :style="{ flex: '1'}" class="line-clamp-1">{{name}}</span>
+ </div>
+ </template>
+ <template #no-options>
+ <div>
+ {{ $gettext('Es gibt keine Kategorie.') }}
+ </div>
+ </template>
+ </StudipSelect>
+ </label>
+ </section>
+ </fieldset>
+ <footer data-dialog-button>
+ <button class="button accept">
+ {{ $gettext('Speichern') }}
+ </button>
+ <button class="button cancel" type="button" data-dialog-close>
+ {{ $gettext('Abbrechen') }}
+ </button>
+ </footer>
+ </form>
+ </div>
+</template>
+
diff --git a/resources/vue/apps/forum/topics/Index.vue b/resources/vue/apps/forum/topics/Index.vue
new file mode 100644
index 0000000..830f76e
--- /dev/null
+++ b/resources/vue/apps/forum/topics/Index.vue
@@ -0,0 +1,100 @@
+<script setup>
+import CreateTopic from "@/vue/components/forum/topics/CreateTopic.vue";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import TopicsIndex from "@/vue/components/forum/topics/TopicsIndex.vue";
+import {computed, onMounted, ref} from "vue";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import StudipPagination from "../../../components/StudipPagination.vue";
+import {topicTransformer} from "../../../components/forum/helpers/transformers";
+import EmptyForum from "../../../components/forum/EmptyForum.vue";
+
+const forumConfig = useForumConfig();
+
+const toggleLayoutMessage = computed(() => {
+ if (forumConfig.tileLayout) {
+ return $gettext('Kachelansicht aktiviert');
+ }
+
+ return $gettext('Tabellarische Ansicht aktiviert');
+});
+
+const topics = ref([]);
+const isLoading = ref(false);
+const pagination = ref({});
+const fetchTopics = async (offset = 0) => {
+ try {
+ isLoading.value = true;
+
+ const response = await STUDIP.jsonapi.withPromises().GET(
+ `courses/${STUDIP.URLHelper.parameters.cid}/forum-topics`,
+ { data: { include: 'category', page: { offset } } }
+ );
+
+ pagination.value = {
+ ...response.meta.page,
+ links: response.links
+ };
+
+ const data = await deserializeJSONAPIResponse(response);
+ topics.value = data.map(topicTransformer);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+
+onMounted(async () => {
+ await fetchTopics()
+})
+</script>
+
+<template>
+ <ForumApp>
+ <EmptyForum v-if="topics.length === 0 && !isLoading" />
+ <template v-else>
+ <header class="header">
+ <div class="header__content header__content--with-actions">
+ <h2>
+ {{ $gettext('Themen') }}
+ </h2>
+ <div class="actions">
+ <CreateTopic v-if="forumConfig.isModerator" />
+ <button
+ v-if="forumConfig.tileLayout"
+ @click="forumConfig.toggleForumLayout();"
+ :title="$gettext('Tabellarische Ansicht')"
+ type="button"
+ class="icon-button">
+ <StudipIcon shape="view-list" :size="20" />
+ </button>
+ <button
+ v-else
+ @click="forumConfig.toggleForumLayout();"
+ :title="$gettext('Kachelansicht')"
+ type="button"
+ class="icon-button">
+ <StudipIcon shape="view-wall" :size="20" />
+ </button>
+ <div aria-live="polite" class="sr-only" role="status">{{ toggleLayoutMessage }}</div>
+ </div>
+ </div>
+ </header>
+ <div class="py-10">
+ <TopicsIndex :topics="topics" :isLoading="isLoading" :showEmptyForumLayout="true">
+ <template #pagination>
+ <StudipPagination
+ v-if="pagination.total > pagination.limit"
+ :currentOffset="pagination.offset"
+ :totalItems="pagination.total"
+ :itemsPerPage="pagination.limit"
+ @updateOffset="fetchTopics" />
+ </template>
+ </TopicsIndex>
+ </div>
+ </template>
+ </ForumApp>
+</template>
diff --git a/resources/vue/apps/forum/topics/Show.vue b/resources/vue/apps/forum/topics/Show.vue
new file mode 100644
index 0000000..12751a6
--- /dev/null
+++ b/resources/vue/apps/forum/topics/Show.vue
@@ -0,0 +1,129 @@
+<script setup>
+import {onMounted, ref} from "vue";
+import ForumApp from "@/vue/components/forum/ForumApp.vue";
+import { default as CreateDiscussion } from "@/vue/components/forum/discussions/Create.vue";
+import DiscussionIndex from "@/vue/components/forum/discussions/DiscussionIndex.vue";
+import {getCategoryURL} from "@/vue/components/forum/helpers/urls";
+import {$gettext} from "../../../../assets/javascripts/lib/gettext";
+import StudipIcon from "../../../components/StudipIcon.vue";
+import StudipDateTime from "../../../components/StudipDateTime.vue";
+import SubscriptionDropdown from "@/vue/components/forum/SubscriptionDropdown.vue";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import StudipPagination from "../../../components/StudipPagination.vue";
+
+const props = defineProps({
+ topic: {
+ type: Object,
+ required: true,
+ },
+ category: {
+ type: Object,
+ required: true,
+ },
+ metadata: {
+ type: Object,
+ required: true,
+ },
+ user_subscription: {
+ type: Object,
+ required: true
+ },
+});
+
+const discussions = ref([]);
+const isLoading = ref(false);
+const pagination = ref({});
+
+const fetchDiscussions = async (offset = 0) => {
+ try {
+ isLoading.value = true;
+
+ const response = await STUDIP.jsonapi.withPromises().GET(
+ `forum-topics/${props.topic.topic_id}/discussions`,
+ {
+ data: { include: 'category,discussion-type,members,tags', page: { offset } }
+ }
+ );
+
+ pagination.value = {
+ ...response.meta.page,
+ links: response.links
+ };
+
+ discussions.value = await deserializeJSONAPIResponse(response);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+
+onMounted(async () => {
+ await fetchDiscussions();
+})
+</script>
+
+<template>
+ <ForumApp class="use-utility-classes forum">
+ <header class="header">
+ <div v-if="category.color" class="flag" :style="{ backgroundColor: category.color}"></div>
+ <div class="header__content header__content--with-actions items-start">
+ <div>
+ <ul class="breadcrumb">
+ <li v-if="category.category_id">
+ <a :href="getCategoryURL(category.category_id)" :title="$gettext('Zur Kategorie')">
+ {{ category.name }}
+ </a>
+ </li>
+ <li>{{ topic.name }}</li>
+ </ul>
+
+ <div class="mt-10 inline-flex gap-20 items-center">
+ <span class="inline-flex gap-5 items-center" :title="$gettext('Anzahl der Teilnehmenden am Thema')" :aria-label="$gettext('Anzahl der Teilnehmenden am Thema')" role="group">
+ <StudipIcon shape="community2" role="info" :size="15" aria-hidden="true" />
+ <small>{{ metadata.users_count }}</small>
+ </span>
+ <span class="inline-flex gap-5 items-center" :title="$gettext('Anzahl der Beiträge')" :aria-label="$gettext('Anzahl der Beiträge')" role="group">
+ <StudipIcon shape="forum" role="info" :size="15" aria-hidden="true"/>
+ <small>{{ metadata.postings_count }}</small>
+ </span>
+ <span class="inline-flex gap-5 items-center" :title="$gettext('Letzte Aktivität')" :aria-label="$gettext('Letzte Aktivität')" role="group">
+ <StudipIcon shape="activity" role="info" :size="15" aria-hidden="true" />
+ <StudipDateTime v-if="metadata.recent_activity" :iso="metadata.recent_activity" :relative="true" />
+ <template v-else>{{ $gettext('Keine Aktivität') }}</template>
+ </span>
+ </div>
+ </div>
+
+ <div class="actions">
+ <CreateDiscussion :topic_id="topic.topic_id" />
+ <SubscriptionDropdown
+ :title="$gettext('Thema abonnieren')"
+ :subject="{
+ id: topic.topic_id,
+ type: 'forum-topics'
+ }"
+ :user_subscription="user_subscription"
+ />
+ </div>
+ </div>
+ </header>
+ <div class="py-10">
+ <DiscussionIndex :discussions="discussions" :isLoading="isLoading">
+ <template #pagination>
+ <tfoot v-if="pagination && pagination.total > pagination.limit">
+ <tr>
+ <td colspan="7">
+ <StudipPagination
+ :currentOffset="pagination.offset"
+ :totalItems="pagination.total"
+ :itemsPerPage="pagination.limit"
+ @updateOffset="fetchDiscussions" />
+ </td>
+ </tr>
+ </tfoot>
+ </template>
+ </DiscussionIndex>
+ </div>
+ </ForumApp>
+</template>
diff --git a/resources/vue/components/Dropdown.vue b/resources/vue/components/Dropdown.vue
new file mode 100644
index 0000000..338e23f
--- /dev/null
+++ b/resources/vue/components/Dropdown.vue
@@ -0,0 +1,55 @@
+<script setup>
+import {useTemplateRef} from "vue";
+import useDetectOutsideClick from "../composables/useDetectOutsideClick";
+import StudipIcon from "./StudipIcon.vue";
+
+defineProps({
+ title: {
+ type: String
+ },
+ withCloseButton: {
+ type: Boolean,
+ default: true
+ }
+})
+
+const isOpen = defineModel({ default: false });
+
+const dropdown = useTemplateRef('dropdown');
+useDetectOutsideClick(dropdown, () => isOpen.value = false)
+</script>
+
+<template>
+ <div
+ v-bind="$attrs"
+ ref="dropdown"
+ class="dropdown"
+ aria-haspopup="true"
+ :aria-expanded="isOpen.toString()"
+ >
+ <slot name="trigger">
+ </slot>
+
+ <Transition name="fade-down">
+ <div v-if="isOpen" class="dropdown__content" aria-labelledby="dropdown-title">
+ <button
+ v-if="withCloseButton"
+ @click="isOpen = false"
+ class="dropdown__close-button">
+ <StudipIcon shape="decline" :size="20" />
+ </button>
+ <div v-if="title" class="dropdown__header">
+ <p id="dropdown-title" class="dropdown__title">
+ {{ title }}
+ </p>
+ </div>
+ <ul class="dropdown__items" role="menu">
+ <slot name="items">
+ </slot>
+ </ul>
+ <slot name="content">
+ </slot>
+ </div>
+ </Transition>
+ </div>
+</template>
diff --git a/resources/vue/components/LinksPreview.vue b/resources/vue/components/LinksPreview.vue
new file mode 100644
index 0000000..7946002
--- /dev/null
+++ b/resources/vue/components/LinksPreview.vue
@@ -0,0 +1,51 @@
+<script setup>
+import {computed, ref} from "vue";
+import StudipIcon from "./StudipIcon.vue";
+import {$gettext} from "../../assets/javascripts/lib/gettext";
+
+const props = defineProps({
+ links: {
+ type: Array,
+ required: true
+ }
+})
+
+const currentIndex = ref(0)
+
+const currentLink = computed(() => props.links[currentIndex.value])
+</script>
+
+<template>
+ <div class="links-preview">
+ <div
+ v-if="links.length > 1"
+ class="links-preview__controls"
+ role="group"
+ :aria-label="$gettext('Link-Vorschau Navigation')"
+ >
+ <button type="button" @click="--currentIndex" :disabled="currentIndex === 0" :title="$gettext('Vorherige Link-Vorschau')" :aria-label="$gettext('Vorherige Link-Vorschau')">
+ <StudipIcon shape="arr_1left" :size="20" aria-hidden="true" />
+ </button>
+ <button type="button" @click="++currentIndex" :disabled="currentIndex === links.length - 1" :title="$gettext('Nächste Link-Vorschau')" :aria-label="$gettext('Nächste Link-Vorschau')">
+ <StudipIcon shape="arr_1right" :size="20" aria-hidden="true" />
+ </button>
+ </div>
+ <div class="links-preview__item" :aria-label="currentLink.title || $gettext('Link-Vorschau')">
+ <a
+ class="og-preview"
+ :href="currentLink.url"
+ target="_blank"
+ :title="$gettext('Vorschau von %{title}', {title: currentLink.title})"
+ :aria-label="$gettext('Vorschau von %{title}', {title: currentLink.title})"
+ >
+ <div class="og-preview__image-container" v-if="currentLink.image">
+ <img :src="currentLink.image" :alt="currentLink.title" />
+ </div>
+ <div class="og-preview__details">
+ <h4 class="og-preview__title">{{ currentLink.title }}</h4>
+ <p class="og-preview__description">{{ currentLink.description }}</p>
+ </div>
+ </a>
+ </div>
+ </div>
+</template>
diff --git a/resources/vue/components/StudipDateTime.vue b/resources/vue/components/StudipDateTime.vue
index 61f498b..4f4ab72 100644
--- a/resources/vue/components/StudipDateTime.vue
+++ b/resources/vue/components/StudipDateTime.vue
@@ -1,55 +1,64 @@
-<template>
- <time :datetime="datetime" v-if="timestamp !== 0" :title="title">
- {{ formatted_date() }}
- </time>
-</template>
+<script setup>
+import { ref, computed, onMounted } from "vue"
+
+const props = defineProps({
+ timestamp: {
+ type: Number,
+ default: 0
+ },
+ iso: {
+ type: String,
+ default: null
+ },
+ relative: {
+ type: Boolean,
+ default: false
+ },
+ date_only: {
+ type: Boolean,
+ default: false
+ }
+})
+
+const now = ref(Date.now())
+
+const date = computed(() => {
+ if (Number.isInteger(props.timestamp) && props.timestamp !== 0) {
+ return new Date(props.timestamp * 1000)
+ } else if (props.iso) {
+ const parsed = new Date(props.iso)
+ return isNaN(parsed.getTime()) ? null : parsed
+ }
+ return null
+})
-<script>
-
- export default {
- name: 'studip-date-time',
- props: {
- timestamp: Number,
- relative: {
- type: Boolean,
- required: false,
- default: false
- },
- date_only: {
- type: Boolean,
- required: false,
- default: false
- }
- },
- computed: {
- datetime () {
- if (!Number.isInteger(this.timestamp)) {
- return '';
- }
- let date = new Date(this.timestamp * 1000);
- return date.toISOString();
- },
- title () {
- return this.display_relative() ? this.formatted_date(true) : null;
- }
- },
- methods: {
- display_relative: function () {
- return Date.now() - this.timestamp * 1000 < 12 * 60 * 60 * 1000;
- },
- formatted_date: function (force_absolute = false) {
- if (!Number.isInteger(this.timestamp)) {
- return `Should be integer: ${this.timestamp}`;
- }
- let date = new Date(this.timestamp * 1000);
- let relative_value = !force_absolute && this.relative && this.display_relative();
- return STUDIP.DateTime.getStudipDate(date, relative_value, this.date_only);
- }
- },
- mounted: function () {
- window.setInterval(() => {
- this.$forceUpdate();
- }, 1000);
- }
+const datetime = computed(() => (date.value ? date.value.toISOString() : ''))
+
+const displayRelative = () => {
+ if (!date.value || !props.relative) {
+ return false
+ }
+ return now.value - date.value.getTime() < 12 * 60 * 60 * 1000
+}
+
+const title = computed(() => (displayRelative() ? formattedDate(true) : null))
+const formattedDate = (forceAbsolute = false) => {
+ if (!date.value) {
+ return 'Invalid date'
}
+ const relativeValue = !forceAbsolute && props.relative && displayRelative()
+ return STUDIP.DateTime.getStudipDate(date.value, relativeValue, props.date_only)
+}
+
+onMounted(() => {
+ window.setInterval(() => {
+ now.value = Date.now()
+ }, 1000)
+})
</script>
+
+<template>
+ <time :datetime="datetime" v-if="date" :title="title">
+ {{ formattedDate() }}
+ </time>
+</template>
diff --git a/resources/vue/components/StudipSwitch.vue b/resources/vue/components/StudipSwitch.vue
new file mode 100644
index 0000000..666f302
--- /dev/null
+++ b/resources/vue/components/StudipSwitch.vue
@@ -0,0 +1,155 @@
+<script setup>
+defineProps({
+ label: {
+ type: String,
+ required: true,
+ }
+});
+
+const isChecked = defineModel({ default: false });
+</script>
+
+<template>
+ <label class="switch-input-container" :title="label">
+ <input
+ v-bind="$attrs"
+ class="input"
+ type="checkbox"
+ :checked="isChecked"
+ @change="isChecked = $event.target.checked"
+ :aria-checked="isChecked.toString()"
+ :aria-label="label"
+ role="switch"
+ />
+ <span class="switch-container">
+ <span class="switch"></span>
+ </span>
+ <span class="label">{{ label }}</span>
+ </label>
+</template>
+
+<style scoped>
+.switch-input-container {
+ cursor: pointer;
+ display: flex !important;
+ align-items: center;
+}
+
+.label {
+ margin-left: 12px;
+ color: #1a202c;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Visually hide the checkbox input */
+.input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
+.switch-container {
+ --studip-switch-container-width: 40px;
+ --studip-switch-size: calc(var(--studip-switch-container-width) / 2);
+ display: flex;
+ align-items: center;
+ position: relative;
+ height: var(--studip-switch-size);
+ flex-basis: var(--studip-switch-container-width);
+ border-radius: var(--studip-switch-size);
+ background-color: var(--dark-gray-color-15);
+ flex-shrink: 0;
+ transition: background-color 0.25s ease-in-out;
+}
+
+.switch-container .switch {
+ content: "";
+ position: absolute;
+ height: calc(var(--studip-switch-size) - 4px);
+ width: calc(var(--studip-switch-size) - 4px);
+ border-radius: 9999px;
+ background-color: white;
+ border: 2px solid var(--dark-gray-color-15);
+ transition: transform 0.375s ease-in-out;
+}
+
+.switch::before {
+ content: '';
+ height: 10px;
+ width: 2px;
+ position: absolute;
+ top: calc(50% - 5px);
+ left: calc(50% - 1px);
+ transform: rotate(45deg);
+ background: var(--color--font-inactive);
+ border-radius: 5px;
+}
+
+.switch::after {
+ content: '';
+ height: 2px;
+ width: 10px;
+ position: absolute;
+ top: calc(50% - 1px);
+ left: calc(50% - 5px);
+ transform: rotate(45deg);
+ background: var(--color--font-inactive);
+ border-radius: 5px;
+}
+
+/* Styles when checked */
+.input:checked + .switch-container {
+ background-color: var(--green-80);
+}
+
+.input:checked + .switch-container .switch {
+ border-color: var(--green-80);
+ transform: translateX(calc(var(--studip-switch-container-width) - var(--studip-switch-size)));
+}
+
+.input:checked + .switch-container .switch::before {
+ position: absolute;
+ top: calc(50%);
+ left: 50%;
+ transform: translateY(-50%) rotate(45deg);
+ background: var(--green);
+}
+
+.input:checked + .switch-container .switch::after {
+ height: 6px;
+ width: 2px;
+ position: absolute;
+ top: calc(55%);
+ left: calc(23%);
+ transform: translateY(-50%) rotate(-40deg);
+ background: var(--green);
+}
+
+/* Focus states */
+.input:focus + .switch-container .switch {
+ border-color: var(--dark-gray-color-60);
+}
+
+.input:focus:checked + .switch-container .switch {
+ border-color: var(--green);
+}
+
+/* Disabled styles */
+.input:disabled + .switch-container {
+ cursor: not-allowed;
+ background-color: var(--dark-gray-color-15);
+}
+
+.input:disabled + .switch-container .switch {
+ background-color: var(--dark-gray-color-40);
+ border-color: var(--dark-gray-color-45);
+}
+</style>
diff --git a/resources/vue/components/UserAvatar.vue b/resources/vue/components/UserAvatar.vue
new file mode 100644
index 0000000..b9c0b1c
--- /dev/null
+++ b/resources/vue/components/UserAvatar.vue
@@ -0,0 +1,102 @@
+<script setup>
+import {$gettext} from "../../assets/javascripts/lib/gettext";
+import StudipIcon from "./StudipIcon.vue";
+
+const props = defineProps({
+ user: {
+ type: Object,
+ required: true
+ }
+});
+
+const isOpen = defineModel({ default: false });
+const AUTH_ID = STUDIP.USER_ID
+const vCardDownloadURL = STUDIP.URLHelper.getURL('dispatch.php/contact/vcard', {'user[]': props.user.username});
+const userProfileURL = STUDIP.URLHelper.getURL('dispatch.php/profile', {username: props.user.username});
+
+const writeMessage = () => {
+ STUDIP.Dialog.fromURL(
+ STUDIP.URLHelper.getURL('dispatch.php/messages/write'),
+ {
+ method: 'get',
+ data: {
+ username: props.user.username,
+ rec_uname: props.user.username
+ }
+ }
+ );
+
+ isOpen.value = false
+}
+
+const openBlubberChat = () => {
+ STUDIP.Dialog.fromURL(
+ STUDIP.URLHelper.getURL(`dispatch.php/blubber/write_to/${props.user.id}`),
+ {
+ method: 'get'
+ }
+ );
+
+ isOpen.value = false
+}
+</script>
+<template>
+ <div class="user-avatar">
+ <div class="user-avatar__header">
+ <img class="user-profile" :src="user.avatar_url" :alt="user.name" />
+ <div class="user-info">
+ <p class="user-name">{{ user.name }}</p>
+ <p v-if="user.motto">{{ user.motto }}</p>
+ </div>
+ </div>
+ <hr />
+ <ul class="user-avatar__actions">
+ <li>
+ <button
+ v-if="user.id !== AUTH_ID"
+ @click="openBlubberChat"
+ class="action-item as-link"
+ :title="$gettext('Blubber diesen Nutzer an')"
+ :aria-label="$gettext('Blubber diesen Nutzer an')"
+ >
+ <StudipIcon shape="blubber" :size="18" aria-hidden="true" />
+ {{ $gettext('Chat starten (blubbern)') }}
+ </button>
+ </li>
+ <li>
+ <a
+ class="action-item"
+ :href="userProfileURL"
+ :title="$gettext('Zum Profil von %{name}', { name: user.name })"
+ :aria-label="$gettext('Zum Profil von %{name}', { name: user.name })"
+ >
+ <StudipIcon shape="role" :size="18" aria-hidden="true" />
+ {{ $gettext('Profil anzeigen') }}
+ </a>
+ </li>
+ <li>
+ <button
+ v-if="user.id !== AUTH_ID"
+ class="action-item as-link"
+ :title="$gettext('Nachricht schreiben')"
+ :aria-label="$gettext('Nachricht schreiben')"
+ @click="writeMessage()"
+ >
+ <StudipIcon shape="mail2" :size="18" aria-hidden="true" />
+ {{ $gettext('Nachricht schreiben') }}
+ </button>
+ </li>
+ <li>
+ <a
+ class="action-item"
+ :href="vCardDownloadURL"
+ :title="$gettext('vCard herunterladen')"
+ :aria-label="$gettext('vCard herunterladen')"
+ >
+ <StudipIcon shape="vcard" :size="18" aria-hidden="true" />
+ {{ $gettext('Vcard speichern') }}
+ </a>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/resources/vue/components/forum/EmptyForum.vue b/resources/vue/components/forum/EmptyForum.vue
new file mode 100644
index 0000000..bc7d25d
--- /dev/null
+++ b/resources/vue/components/forum/EmptyForum.vue
@@ -0,0 +1,40 @@
+<script setup>
+import {getDiscussionCreateURL} from "./helpers/urls";
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+
+const emptyForumIllustration = `${STUDIP.ASSETS_URL}images/forum/forum-keyvisual-positive.svg`;
+</script>
+
+<template>
+ <div class="empty-forum">
+ <div class="forum-illustration">
+ <img :src="emptyForumIllustration" :alt="$gettext('Leeres Forum – Illustration')" />
+ </div>
+ <div>
+ <h2>{{ $gettext('Starten Sie den Austausch') }}</h2>
+ <p>
+ {{ $gettext('Das Forum ist ein Ort für Kommunikation und Zusammenarbeit. Dieser entsteht bereits mit dem ersten Beitrag.') }}
+ </p>
+ <p>
+ {{ $gettext('Noch ist es ziemlich ruhig hier. Warum starten Sie nicht einfach mit einer Frage zum Veranstaltungsthema, die Sie beschäftigt? Oder Sie teilen eine Idee, die noch Personen zum Mitmachen sucht?') }}
+ </p>
+ <p>
+ {{ $gettext('Das Forum ist offen für alles, was weiterhilft, interessiert oder zum Nachdenken anregt. Manchmal braucht es nur einen kleinen Anstoß. Klicken Sie dafür auf „Eine Diskussion starten“ und erstellen den ersten Forumsbeitrag.') }}
+ </p>
+
+ <div class="buttons-container">
+ <button type="button" class="button --with-icon">
+ <StudipIcon shape="lightbulb" :size="20" class="icon-default" aria-hidden="true" />
+ <StudipIcon shape="lightbulb" :size="20" class="icon-hover" role="info_alt" aria-hidden="true" />
+ {{ $gettext('Tour ansehen') }}
+ </button>
+ <a :href="getDiscussionCreateURL()" data-dialog="width=900;height=700" class="button --with-icon">
+ <StudipIcon shape="add" :size="20" class="icon-default" aria-hidden="true" />
+ <StudipIcon shape="add" :size="20" class="icon-hover" role="info_alt" aria-hidden="true" />
+ {{ $gettext('Eine Diskussion starten') }}
+ </a>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/resources/vue/components/forum/ForumApp.vue b/resources/vue/components/forum/ForumApp.vue
new file mode 100644
index 0000000..60c8f8d
--- /dev/null
+++ b/resources/vue/components/forum/ForumApp.vue
@@ -0,0 +1,34 @@
+<script setup>
+import {onMounted} from "vue";
+import {useForumConfig} from "../../store/pinia/forum/ForumConfig";
+
+const forumConfig = useForumConfig();
+
+onMounted(async () => {
+ try {
+ const response = await STUDIP.jsonapi.withPromises().GET(`courses/${STUDIP.URLHelper.parameters.cid}/forum-configs`);
+
+ forumConfig.$patch({
+ isModerator: response.meta['is-moderator'],
+ isAdmin: response.meta['is-admin'],
+ anonymousPost: response.meta['anonymous-post'],
+ tileLayout: response.meta['tile-layout'],
+ });
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+})
+</script>
+
+<template>
+ <div class="forum">
+ <div class="forum__container use-utility-classes">
+ <div>
+ <slot />
+ </div>
+ <div class="forum__sidebar">
+ <slot name="sidebar" />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/resources/vue/components/forum/ForumMembers.vue b/resources/vue/components/forum/ForumMembers.vue
new file mode 100644
index 0000000..7e0e385
--- /dev/null
+++ b/resources/vue/components/forum/ForumMembers.vue
@@ -0,0 +1,134 @@
+<script setup>
+import {computed, ref} from "vue";
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import UserAvatarDropdown from "./UserAvatarDropdown.vue";
+import Dropdown from "../Dropdown.vue";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import UserAvatar from "../UserAvatar.vue";
+
+const props = defineProps({
+ members: {
+ type: Array,
+ required: true,
+ },
+ limit: {
+ type: Number,
+ default: 4
+ },
+ size: {
+ type: String,
+ default: '25px',
+ }
+});
+
+const showAllMembers = ref(false);
+const activeUserAvatar = ref('');
+
+const moderators = computed(() => props.members.filter(({ role }) => role === 'moderator'));
+const authors = computed(() => props.members.filter(({ role }) => role === 'author'));
+
+const remainedMembersCount = computed(() => props.members.length - props.members.slice(0, props.limit).length);
+const isModerator = role => role === 'moderator';
+</script>
+
+<template>
+ <ul class="forum-members">
+ <li v-for="(user, index) in [...moderators, ...authors].slice(0, limit)" :key="index">
+ <UserAvatarDropdown
+ :user="user"
+ :size="size"
+ :label="isModerator(user.role) ? `${$gettext('Moderator')}: ${user.name}` : `${$gettext('Autor:in')}: ${user.name}`"
+ :class="{
+ 'moderator': isModerator(user.role)
+ }"
+ />
+ </li>
+ <li v-if="remainedMembersCount" class="remained-users" aria-live="polite">
+ <Dropdown v-model="showAllMembers">
+ <template #trigger>
+ <button
+ type="button"
+ class="remained-users__button"
+ @click="showAllMembers = !showAllMembers"
+ :title="$gettext('Alle Teilnehmende anzeigen')"
+ :aria-label="$gettext('Alle Teilnehmende anzeigen')"
+ >
+ <span class="remained-users__count" :style="{ width: size, height: size }">
+ +{{ remainedMembersCount }}
+ </span>
+ </button>
+ </template>
+
+ <template #content>
+ <div class="forum-users-dropdown">
+ <div class="user-group user-group--moderators">
+ <p class="user-group__title">{{ $gettext('Moderatoren') }}</p>
+ <ul class="user-group__list">
+ <li v-for="(user, index) in moderators" :key="index">
+ <div
+ v-if="activeUserAvatar !== user.id"
+ class="user-item"
+ >
+ <div class="user-item__user">
+ <img :src="user.avatar_url" :alt="user.name" />
+ <p>{{ user.name }}</p>
+ </div>
+ <button
+ @click="activeUserAvatar = user.id"
+ :title="$gettext('Aufklappen')"
+ :aria-label="$gettext('Aufklappen')"
+ class="show-avatar">
+ <StudipIcon shape="arr_1down" :size="15" aria-hidden="true" />
+ </button>
+ </div>
+ <button
+ v-else
+ @click="activeUserAvatar = ''"
+ :title="$gettext('Zuklappen')"
+ :aria-label="$gettext('Zuklappen')"
+ class="hide-avatar">
+ <StudipIcon shape="arr_1up" :size="15" aria-hidden="true" />
+ </button>
+ <UserAvatar v-if="activeUserAvatar === user.id" :user="user" />
+ </li>
+ </ul>
+ </div>
+ <hr />
+ <div class="user-group">
+ <p class="user-group__title">{{ $gettext('Autor:in') }}</p>
+ <ul class="user-group__list">
+ <li v-for="(user, index) in authors" :key="index">
+ <div
+ v-if="activeUserAvatar !== user.id"
+ class="user-item"
+ >
+ <div class="user-item__user">
+ <img :src="user.avatar_url" :alt="user.name" />
+ <p>{{ user.name }}</p>
+ </div>
+ <button
+ @click="activeUserAvatar = user.id"
+ :title="$gettext('Aufklappen')"
+ :aria-label="$gettext('Aufklappen')"
+ class="show-avatar">
+ <StudipIcon shape="arr_1down" :size="15" aria-hidden="true" />
+ </button>
+ </div>
+ <button
+ v-else
+ @click="activeUserAvatar = ''"
+ :title="$gettext('Zuklappen')"
+ :aria-label="$gettext('Zuklappen')"
+ class="hide-avatar">
+ <StudipIcon shape="arr_1up" :size="15" aria-hidden="true" />
+ </button>
+ <UserAvatar v-if="activeUserAvatar === user.id" :user="user" />
+ </li>
+ </ul>
+ </div>
+ </div>
+ </template>
+ </Dropdown>
+ </li>
+ </ul>
+</template>
diff --git a/resources/vue/components/forum/Loader.vue b/resources/vue/components/forum/Loader.vue
new file mode 100644
index 0000000..17584bb
--- /dev/null
+++ b/resources/vue/components/forum/Loader.vue
@@ -0,0 +1,19 @@
+<script setup>
+const url = `${STUDIP.ASSETS_URL}images/loading-indicator.svg`;
+
+defineProps({
+ size: {
+ type: Number,
+ default: 30
+ }
+});
+</script>
+
+<template>
+ <img
+ v-bind="$attrs"
+ :src="url"
+ alt=""
+ :style="{ width: size + 'px', height: size + 'px' }"
+ />
+</template>
diff --git a/resources/vue/components/forum/SelectTagsInput.vue b/resources/vue/components/forum/SelectTagsInput.vue
new file mode 100644
index 0000000..2582852
--- /dev/null
+++ b/resources/vue/components/forum/SelectTagsInput.vue
@@ -0,0 +1,35 @@
+<script setup>
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import StudipSelect from "..//StudipSelect.vue";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+</script>
+
+<template>
+ <StudipSelect
+ v-bind="$attrs"
+ class="multi-select-input"
+ :placeholder="$gettext('Schlagworte')"
+ label="name"
+ :clearable="true"
+ :reduce="(tag) => {
+ if(tag.name) {
+ return tag;
+ }
+
+ return { name: tag };
+ }"
+ :closeOnSelect="false"
+ >
+ <template #open-indicator>
+ <StudipIcon shape="add" :size="15" />
+ </template>
+
+ <template #selected-option="{name}">
+ <span>{{ name }}</span>
+ </template>
+
+ <template #no-options>
+ <div>{{ $gettext('Es gibt keine Schlagworte.') }}</div>
+ </template>
+ </StudipSelect>
+</template>
diff --git a/resources/vue/components/forum/SelectUserInput.vue b/resources/vue/components/forum/SelectUserInput.vue
new file mode 100644
index 0000000..68bd283
--- /dev/null
+++ b/resources/vue/components/forum/SelectUserInput.vue
@@ -0,0 +1,30 @@
+<script setup>
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import StudipSelect from "..//StudipSelect.vue";
+</script>
+
+<template>
+ <StudipSelect
+ label="name"
+ :placeholder="$gettext('Autor/-in')"
+ >
+ <template #selected-option="{name, avatar_url}">
+ <div class="flex items-center">
+ <img :src="avatar_url" :alt="name" :style="{ height: '14px', width: '14px', marginRight: '8px'}" />
+ <span class="line-clamp-1 flex-1">{{ name }}</span>
+ </div>
+ </template>
+
+ <template #option="{name, avatar_url}">
+ <div :style="{ display: 'flex', alignItems: 'center' }">
+ <img :src="avatar_url" :alt="name" :style="{ height: '14px', width: '14px', marginRight: '8px'}" />
+ <span :style="{ flex: '1'}" class="line-clamp-1 flex-1">{{ name }}</span>
+ </div>
+ </template>
+ <template #no-options>
+ <div>
+ {{ $gettext('Es gibt keine Benutzer/-innen.') }}
+ </div>
+ </template>
+ </StudipSelect>
+</template>
diff --git a/resources/vue/components/forum/SubscriptionDropdown.vue b/resources/vue/components/forum/SubscriptionDropdown.vue
new file mode 100644
index 0000000..c641011
--- /dev/null
+++ b/resources/vue/components/forum/SubscriptionDropdown.vue
@@ -0,0 +1,207 @@
+<script setup>
+import {computed, ref} from "vue";
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import Dropdown from "../Dropdown.vue";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import {SubscriptionNotificationType} from "@/vue/components/forum/enums/SubscriptionNotificationType";
+import {deserializeJSONAPIResponse} from "../../../assets/javascripts/lib/jsonapiUtils";
+
+const emit = defineEmits(['updated', 'deleted']);
+const props = defineProps({
+ user_subscription: {
+ type: Object,
+ required: true
+ },
+ subject: {
+ type: Object,
+ required: true
+ },
+ title: {
+ type: String,
+ default: $gettext('Diskussion abonnieren')
+ }
+});
+
+const isOpen = ref(false);
+const subscription = ref(props.user_subscription);
+const isLoading = ref(false);
+
+const subscriptionButtonLabel = computed(() => {
+ if (subscription.value) {
+ switch (subscription.value.notification_type) {
+ case SubscriptionNotificationType.All:
+ return $gettext('Alle');
+ case SubscriptionNotificationType.RepliesOnly:
+ return $gettext('Nur Zitate');
+ case SubscriptionNotificationType.None:
+ return $gettext('Keine');
+ }
+ }
+
+ return '';
+})
+
+const subscriptionButtonIcon = computed(() => {
+ if (subscription.value) {
+ switch (subscription.value.notification_type) {
+ case SubscriptionNotificationType.All:
+ return 'subscription-all';
+ case SubscriptionNotificationType.RepliesOnly:
+ return 'subscription-quotes';
+ case SubscriptionNotificationType.None:
+ return 'subscription-none';
+ }
+ }
+
+ return 'subscription-all';
+});
+
+const getSubscriptionJSONAPIObject = (notification_type = 'all') => ({
+ data: {
+ id: subscription.value?.id,
+ type: 'forum-subscriptions',
+ attributes: {
+ 'notification-type': notification_type
+ },
+ relationships: {
+ subject: {
+ data: {
+ type: props.subject.type,
+ id: props.subject.id
+ }
+ },
+ range: {
+ data: {
+ type: 'courses',
+ id: STUDIP.URLHelper.parameters.cid
+ }
+ }
+ }
+ }
+})
+
+const unSubscribe = async () => {
+ if (!subscription.value?.notification_type) {
+ return;
+ }
+
+ try {
+ isLoading.value = true;
+
+ await STUDIP.jsonapi.withPromises().DELETE(`forum-subscriptions/${subscription.value.id}`);
+
+ emit('deleted', subscription);
+ subscription.value = null;
+
+ STUDIP.Report.success($gettext('Sie haben das Abonnement erfolgreich beendet.'));
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+
+const subscribe = async (notification_type = 'all') => {
+ try {
+ isLoading.value = false;
+
+ const response = await STUDIP.jsonapi.withPromises().POST(
+ 'forum-subscriptions',
+ {
+ data: getSubscriptionJSONAPIObject(notification_type)
+ }
+ );
+
+ const data = await deserializeJSONAPIResponse(response);
+ subscription.value = data;
+ emit('updated', data);
+
+ STUDIP.Report.success($gettext('Erfolgreich abonniert!'), subscriptionButtonLabel);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+</script>
+
+<template>
+ <Dropdown class="forum-subscriptions-dropdown" v-model="isOpen" :title="title">
+ <template #trigger>
+ <button class="icon-button subscription-button" type="button" @click="isOpen = !isOpen" :title="title">
+ <span v-if="subscriptionButtonLabel">
+ {{ subscriptionButtonLabel }}
+ </span>
+ <StudipIcon :shape="subscriptionButtonIcon" :size="20" />
+ </button>
+ </template>
+
+ <template #items>
+ <li
+ tabindex="0"
+ :class="{
+ '--active': subscription?.notification_type === SubscriptionNotificationType.All
+ }"
+ @keydown.enter="subscribe(SubscriptionNotificationType.All)"
+ @click="subscribe(SubscriptionNotificationType.All)"
+ >
+ <StudipIcon shape="subscription-all" :size="25" />
+ <div class="subscription-option">
+ <p class="option-title">{{ $gettext('Alle Benachrichtigungen') }}</p>
+ <StudipIcon
+ v-if="subscription?.notification_type === SubscriptionNotificationType.All"
+ shape="accept"
+ :size="20"
+ role="accept" />
+ </div>
+ </li>
+ <li
+ tabindex="0"
+ :class="{
+ '--active': subscription?.notification_type === SubscriptionNotificationType.RepliesOnly
+ }"
+ @keydown.enter="subscribe(SubscriptionNotificationType.RepliesOnly)"
+ @click="subscribe(SubscriptionNotificationType.RepliesOnly)"
+ >
+ <StudipIcon shape="subscription-quotes" :size="25" />
+ <div class="subscription-option">
+ <p class="option-title">{{ $gettext('Nur Zitat') }}</p>
+ <StudipIcon
+ v-if="subscription?.notification_type === SubscriptionNotificationType.RepliesOnly"
+ shape="accept"
+ :size="20"
+ role="accept" />
+ </div>
+ </li>
+ <li
+ tabindex="0"
+ :class="{
+ '--active': subscription?.notification_type === SubscriptionNotificationType.None
+ }"
+ @keydown.enter="subscribe(SubscriptionNotificationType.None)"
+ @click="subscribe(SubscriptionNotificationType.None)"
+ >
+ <StudipIcon shape="subscription-none" :size="25" />
+ <div class="subscription-option">
+ <p class="option-title">{{ $gettext('Keine') }}</p>
+ <StudipIcon
+ v-if="subscription?.notification_type === SubscriptionNotificationType.None"
+ shape="accept"
+ :size="20"
+ role="accept" />
+ </div>
+ </li>
+ <li
+ :tabindex="subscription ? 0 : -1"
+ :class="{
+ '--disabled': !subscription?.notification_type
+ }"
+ @keydown.enter="unSubscribe"
+ @click="unSubscribe"
+ >
+ <StudipIcon shape="subscription-end" :size="25" />
+ <p class="option-title">{{ $gettext('Abonnieren beenden') }}</p>
+ </li>
+ </template>
+ </Dropdown>
+</template>
diff --git a/resources/vue/components/forum/UserAvatarDropdown.vue b/resources/vue/components/forum/UserAvatarDropdown.vue
new file mode 100644
index 0000000..dec1658
--- /dev/null
+++ b/resources/vue/components/forum/UserAvatarDropdown.vue
@@ -0,0 +1,44 @@
+<script setup>
+import {$gettext} from "../../../assets/javascripts/lib/gettext";
+import Dropdown from "../Dropdown.vue";
+import UserAvatar from "../UserAvatar.vue";
+
+defineProps({
+ user: {
+ type: Object,
+ required: true
+ },
+ size: {
+ type: String,
+ default: '25px',
+ },
+ label: {
+ type: String,
+ default: ''
+ }
+});
+
+const isOpen = defineModel({ default: false });
+</script>
+<template>
+ <Dropdown class="user-avatar-dropdown" v-model="isOpen">
+ <template #trigger>
+ <button
+ class="user-avatar-dropdown__preview"
+ @click="isOpen = !isOpen"
+ v-bind="$attrs"
+ :class="{
+ 'active': isOpen
+ }"
+ :title="label ?? user.name"
+ :aria-label="label ?? $gettext('vCard')"
+ >
+ <img class="user-profile" :src="user.avatar_url" :style="{ width: size, height: size }" :alt="user.name" />
+ </button>
+ </template>
+
+ <template #content>
+ <UserAvatar :user="user" v-model="isOpen" />
+ </template>
+ </Dropdown>
+</template>
diff --git a/resources/vue/components/forum/categories/CategoryItem.vue b/resources/vue/components/forum/categories/CategoryItem.vue
new file mode 100644
index 0000000..80463fa
--- /dev/null
+++ b/resources/vue/components/forum/categories/CategoryItem.vue
@@ -0,0 +1,212 @@
+<script setup>
+import {getCategoryDeleteURL, getCategoryEditURL, getCategoryURL} from "../helpers/urls";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import StudipDateTime from "@/vue/components/StudipDateTime.vue";
+import StudipActionMenu from "@/vue/components/StudipActionMenu.vue";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import {computed} from "vue";
+
+const forumConfig = useForumConfig();
+
+const props = defineProps({
+ category: {
+ type: Object,
+ required: true,
+ },
+ renderType: {
+ type: String,
+ default: 'card'
+ }
+});
+
+const categoryActionMenus = computed(() => {
+ if (forumConfig.isModerator) {
+ return [
+ { label: $gettext('Kategorie bearbeiten'), icon: 'edit', emit: 'edit'},
+ { label: $gettext('Kategorie löschen'), icon: 'trash', emit: 'delete'}
+ ];
+ }
+
+ return [];
+});
+
+const editCategory = () => STUDIP.Dialog.fromURL(getCategoryEditURL(props.category.id), { width: '700' });
+
+const deleteCategory = () => STUDIP.Dialog.confirm(
+ $gettext('Wollen Sie diese "%{name}" Kategorie löschen?', {name: props.category.name}),
+ () => window.location = getCategoryDeleteURL(props.category.id),
+ STUDIP.Dialog.close()
+);
+</script>
+
+<template>
+ <tr v-if="renderType === 'tr'">
+ <td>
+ <div class="topic-overview">
+ <div class="flag" v-if="category.color" :style="{ backgroundColor: category.color}"></div>
+ <div class="content">
+ <div class="title-with-actions">
+ <div class="title-with-actions__content">
+ <a
+ class="title-with-actions__link"
+ :href="getCategoryURL(category.id)"
+ :title="$gettext('Zur Kategorie')">
+ <h3 class="line-clamp-2">{{ category.name }}</h3>
+ <span
+ v-if="category.meta.postings_count > category.meta.user_read_index"
+ class="unread-items-badge"
+ role="status"
+ aria-live="polite"
+ :aria-label="$gettext('Sie haben %{count} ungelesene Beiträge', {count: category.meta.postings_count - category.meta.user_read_index})"
+ :title="$gettext('Sie haben %{count} ungelesene Beiträge', {count: category.meta.postings_count - category.meta.user_read_index})"
+ >
+ {{ category.meta.postings_count - category.meta.user_read_index }}
+ </span>
+ </a>
+ </div>
+
+ <div class="title-with-actions__actions-xs">
+ <StudipActionMenu
+ :items="categoryActionMenus"
+ @edit="editCategory"
+ @delete="deleteCategory"
+ />
+ </div>
+ </div>
+ <p>
+ <small class="line-clamp-3">{{ category.description }}</small>
+ </p>
+ </div>
+ </div>
+
+ <!--mobile display: start-->
+ <div class="details-xs">
+ <dl>
+ <dt>{{ $gettext('Anzahl der Teilnehmenden in der Kategorie') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="community2" role="info" :size="15" />
+ {{ category.meta.users_count }}
+ </dd>
+ </dl>
+
+ <dl>
+ <dt>{{ $gettext('Anzahl der Diskussionen') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="forum" role="info" :size="15" />
+ {{ category.meta.discussions_count }}
+ </dd>
+
+ <dt>{{ $gettext('Anzahl der Beiträge') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="reply" role="info" :size="15" />
+ {{ category.meta.postings_count }}
+ </dd>
+
+ <dt>{{ $gettext('Aktivitäten') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="activity" role="info" :size="15" />
+ <StudipDateTime v-if="category.meta.recent_activity" :iso="category.meta.recent_activity" :relative="true" />
+ <template v-else>{{ $gettext('Keine Aktivität') }}</template>
+ </dd>
+ </dl>
+ </div>
+ <!--mobile display: end-->
+ </td>
+ <td class="nowrap" :title="$gettext('Anzahl der Diskussionen')" :aria-label="$gettext('Anzahl der Diskussionen')">
+ {{ category.meta.discussions_count }} {{ $gettext('Diskussion') }}
+ </td>
+ <td>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Teilnehmenden in der Kategorie')" :aria-label="$gettext('Anzahl der Teilnehmenden in der Kategorie')" role="group">
+ <StudipIcon shape="community2" role="info" :size="20" aria-hidden="true" />
+ <span>{{ category.meta.users_count }}</span>
+ </span>
+ </td>
+ <td>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Beiträge')" :aria-label="$gettext('Anzahl der Beiträge')" role="group">
+ <StudipIcon shape="reply" role="info" :size="20" aria-hidden="true" />
+ <span>{{ category.meta.postings_count }}</span>
+ </span>
+ </td>
+ <td>
+ <span class="inline-flex gap-10 items-center nowrap" :title="$gettext('Letzte Aktivität')" :aria-label="$gettext('Letzte Aktivität')" role="group">
+ <StudipIcon shape="activity" role="info" :size="20" aria-hidden="true"/>
+ <StudipDateTime v-if="category.meta.recent_activity" :iso="category.meta.recent_activity" :relative="true" />
+ <template v-else>{{ $gettext('Keine Aktivität') }}</template>
+ </span>
+ </td>
+ <td class="actions">
+ <StudipActionMenu
+ :items="categoryActionMenus"
+ @edit="editCategory"
+ @delete="deleteCategory"
+ />
+ </td>
+ </tr>
+ <a
+ v-else
+ :href="getCategoryURL(category.id)"
+ :title="$gettext('Zur Kategorie')"
+ class="styleless">
+ <div
+ class="topic-card"
+ :class="{
+ '--with-hover-style': category.color
+ }"
+ :style="{
+ '--forum-topic-card-hover-border-color': category.color
+ }"
+ >
+ <div v-if="category.color" class="topic-card__flag" :style="{ backgroundColor: category.color}"></div>
+ <div class="topic-card__content">
+ <div class="topic-card__body">
+ <div class="flex space-between">
+ <div class="flex items-start gap-10">
+ <h3 class="topic-card__title line-clamp-2">
+ {{ category.name }}
+ </h3>
+
+ <span
+ v-if="category.meta.postings_count > category.meta.user_read_index"
+ class="unread-items-badge"
+ role="status"
+ aria-live="polite"
+ :aria-label="$gettext('Sie haben %{count} ungelesene Beiträge', {count: category.meta.postings_count - category.meta.user_read_index})"
+ :title="$gettext('Sie haben %{count} ungelesene Beiträge', {count: category.meta.postings_count - category.meta.user_read_index})"
+ >
+ {{ category.meta.postings_count - category.meta.user_read_index }}
+ </span>
+ </div>
+ <div class="actions">
+ <StudipActionMenu
+ :items="categoryActionMenus"
+ @edit="editCategory"
+ @delete="deleteCategory"
+ />
+ </div>
+ </div>
+ <p>
+ <small class="line-clamp-3">{{ category.description }}</small>
+ </p>
+ </div>
+ <div class="topic-card__footer">
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Teilnehmenden in der Kategorie')" :aria-label="$gettext('Anzahl der Teilnehmenden in der Kategorie')" role="group">
+ <StudipIcon shape="community2" role="info" :size="15" aria-hidden="true"/>
+ <small>{{ category.meta.users_count }}</small>
+ </span>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Beiträge')" :aria-label="$gettext('Anzahl der Beiträge')" role="group">
+ <StudipIcon shape="reply" role="info" :size="15" aria-hidden="true"/>
+ <small>{{ category.meta.postings_count }}</small>
+ </span>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Letzte Aktivität')" :aria-label="$gettext('Letzte Aktivität')" role="group">
+ <StudipIcon shape="activity" role="info" :size="15" aria-hidden="true"/>
+ <small v-if="category.meta.recent_activity">
+ <StudipDateTime :iso="category.meta.recent_activity" :relative="true" />
+ </small>
+ <small v-else>{{ $gettext('Keine Aktivität') }}</small>
+ </span>
+ </div>
+ </div>
+ </div>
+ </a>
+</template>
diff --git a/resources/vue/components/forum/categories/Create.vue b/resources/vue/components/forum/categories/Create.vue
new file mode 100644
index 0000000..fa79752
--- /dev/null
+++ b/resources/vue/components/forum/categories/Create.vue
@@ -0,0 +1,27 @@
+
+<script setup>
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import {getCategoryCreateURL} from "../helpers/urls";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+
+defineProps({
+ label: {
+ type: String,
+ default: ''
+ }
+});
+</script>
+
+<template>
+ <a
+ :href="getCategoryCreateURL()"
+ data-dialog="size=700"
+ :title="$gettext('Neue Kategorie anlegen')"
+ :aria-label="$gettext('Neue Kategorie anlegen')"
+ class="icon-button"
+ role="button"
+ >
+ <StudipIcon shape="add" :size="20" aria-hidden="true" />
+ <span v-if="label" class="label">{{ label }}</span>
+ </a>
+</template>
diff --git a/resources/vue/components/forum/discussions/Create.vue b/resources/vue/components/forum/discussions/Create.vue
new file mode 100644
index 0000000..f4d3fca
--- /dev/null
+++ b/resources/vue/components/forum/discussions/Create.vue
@@ -0,0 +1,30 @@
+
+<script setup>
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import {computed} from "vue";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+
+const forumConfig = useForumConfig();
+const props = defineProps({
+ topic_id: {
+ type: String,
+ }
+});
+
+const discussionCreateURL = computed(() => {
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussions/edit?topic_id=${props.topic_id}`);
+});
+</script>
+
+<template>
+ <a
+ v-if="forumConfig.isModerator"
+ :href="discussionCreateURL"
+ :title="$gettext('Neue Diskussion starten')"
+ data-dialog="width=900;height=750"
+ type="button"
+ class="icon-button">
+ <StudipIcon shape="add" :size="20" aria-hidden="true" />
+ </a>
+</template>
diff --git a/resources/vue/components/forum/discussions/DiscussionIndex.vue b/resources/vue/components/forum/discussions/DiscussionIndex.vue
new file mode 100644
index 0000000..748d291
--- /dev/null
+++ b/resources/vue/components/forum/discussions/DiscussionIndex.vue
@@ -0,0 +1,290 @@
+<script setup>
+import {getDiscussionURL, getSearchURL} from "../helpers/urls";
+import {numberFormatter} from "../../../../assets/javascripts/lib/number_formatter";
+import ForumMembers from "../ForumMembers.vue";
+import {useSortable} from "../../../composables/useSortable";
+import {toRef} from "vue";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import StudipDateTime from "@/vue/components/StudipDateTime.vue";
+import StudipActionMenu from "@/vue/components/StudipActionMenu.vue";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import Loader from "../Loader.vue";
+
+const forumConfig = useForumConfig();
+
+const props = defineProps({
+ discussions: {
+ type: Array,
+ required: true
+ },
+ withActions: {
+ type: Boolean,
+ default: true
+ },
+ redirect: {
+ type: String,
+ default: 'topic'
+ },
+ isLoading: {
+ type: Boolean,
+ default: false
+ }
+});
+
+const discussionsRef = toRef(props, 'discussions');
+
+const {
+ sortedData,
+ sortBy,
+ getSortClass,
+ getAriaSortString,
+ getAriaSortLabel
+} = useSortable(discussionsRef);
+
+const getActionMenusItems = () => {
+ if (forumConfig.isModerator) {
+ return [
+ { label: $gettext('Bearbeiten'), icon: 'edit', emit: 'edit'},
+ { label: $gettext('Löschen'), icon: 'trash', emit: 'delete'}
+ ];
+ }
+
+ return [];
+}
+
+const editDiscussion = id => STUDIP.Dialog.fromURL(
+ STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussions/edit/${id}`),
+ {
+ width: '900'
+ }
+);
+
+const deleteDiscussion = id => STUDIP.Dialog.confirm(
+ $gettext('Wollen Sie diese Diskussion löschen? Damit werden auch alle Beiträge gelöscht!'),
+ () => {
+ window.location = STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussions/delete/${id}`);
+ },
+ STUDIP.Dialog.close()
+);
+</script>
+
+<template>
+ <table class="default forum-table --discussions-index">
+ <colgroup>
+ <col>
+ <col style="width: 15%;">
+ <col style="width: 10%;">
+ <col style="width: 10%;">
+ <col style="width: 10%;">
+ <col style="width: 5%">
+ <col v-if="withActions" style="width: 10%">
+ </colgroup>
+ <thead>
+ <tr class="sortable">
+ <th
+ :class="getSortClass('title')"
+ :aria-sort="getAriaSortString('title')"
+ :aria-label="getAriaSortLabel('title', $gettext('Diskussionstitel'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('title')"
+ :title="$gettext('Nach Diskussionstitel sortieren')">
+ {{ $gettext('Diskussion') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('members')"
+ :aria-sort="getAriaSortString('members')"
+ :aria-label="getAriaSortLabel('members', $gettext('Anzahl der Teilnehmenden'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('members')"
+ :title="$gettext('Nach Anzahl der Teilnehmenden sortieren')">
+ {{ $gettext('Teilnehmende') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.postings_count')"
+ :aria-sort="getAriaSortString('meta.postings_count')"
+ :aria-label="getAriaSortLabel('meta.postings_count', $gettext('Anzahl der Beiträge'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.postings_count')"
+ :title="$gettext('Nach Anzahl der Beiträge sortieren')">
+ {{ $gettext('Beiträge') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('view_count')"
+ :aria-sort="getAriaSortString('view_count')"
+ :aria-label="getAriaSortLabel('view_count', $gettext('Anzahl der Aufrufe'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('view_count')"
+ :title="$gettext('Nach Anzahl der Aufrufe sortieren')">
+ {{ $gettext('Aufrufe') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.recent_activity')"
+ :aria-sort="getAriaSortString('meta.recent_activity')"
+ :aria-label="getAriaSortLabel('meta.recent_activity', $gettext('Aktivitäten'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.recent_activity')"
+ :title="$gettext('Nach Aktivitäten sortieren')">
+ {{ $gettext('Aktivitäten') }}
+ </a>
+ </th>
+ <th></th>
+ <th v-if="withActions"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-if="isLoading" >
+ <td colspan="7">
+ <Loader />
+ </td>
+ </tr>
+ <template v-else>
+ <tr v-for="discussion in sortedData" :key="discussion.id">
+ <td>
+ <div class="discussion-overview">
+ <div class="title-with-actions">
+ <div class="title-with-actions__content">
+ <StudipIcon class="icon" v-if="discussion.sticky" shape="pin" role="info" :size="20" />
+ <a
+ class="title-with-actions__link"
+ :href="getDiscussionURL(discussion.id, {redirect})"
+ :title="$gettext('Zur Diskussion')">
+ <h3 class="title-with-actions_title line-clamp-2 m-0">{{ discussion.title }}</h3>
+ <span
+ v-if="discussion.meta.postings_count > discussion.meta.user_read_index"
+ class="unread-items-badge"
+ role="status"
+ aria-live="polite"
+ :aria-label="$gettext('Sie haben %{count} ungelesene Beiträge', {count: discussion.meta.postings_count - discussion.meta.user_read_index})"
+ :title="$gettext('Sie haben %{count} ungelesene Beiträge', {count: discussion.meta.postings_count - discussion.meta.user_read_index})"
+ >
+ {{ discussion.meta.postings_count - discussion.meta.user_read_index }}
+ </span>
+ </a>
+ </div>
+ <div class="title-with-actions__actions-xs">
+ <StudipActionMenu
+ v-if="withActions"
+ :items="getActionMenusItems(discussion)"
+ @edit="editDiscussion(discussion.id)"
+ @delete="deleteDiscussion(discussion.id)"
+ />
+ </div>
+ </div>
+ <div class="inline-flex gap-10 items-start mb-10 mt-10">
+ <div v-if="discussion.category" class="discussion-category">
+ <span :style="{ width: '12px', height: '12px', backgroundColor: discussion.category.color }"></span>
+ <p class="m-0">
+ {{ discussion.category.name }}
+ </p>
+ </div>
+ <ul class="tags-container">
+ <li v-if="discussion.discussion_type?.name" class="tags-container__tag">
+ <StudipIcon :shape="discussion.discussion_type.icon" :size="12" :title="discussion.discussion_type.name" role="info" />
+ </li>
+ <template v-for="tag in discussion.tags" :key="tag.id">
+ <li class="tags-container__tag">
+ <a :href="getSearchURL(`tag_ids[]=${tag.id}`)" :title="$gettext('Zum Schlagwort')" :aria-label="$gettext('Zum Schlagwort')">
+ {{ '#'+tag.name }}
+ </a>
+ </li>
+ </template>
+ </ul>
+ </div>
+ </div>
+ <!--mobile display: start-->
+ <div class="details-xs mt-10">
+ <dl>
+ <dt>{{ $gettext('Aufrufe') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="block-eyecatcher" :size="15" role="info" />
+ {{ numberFormatter(discussion.view_count, 1) }}
+ </dd>
+
+ <dt>{{ $gettext('Anzahl der Beitrage') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="forum" :size="15" role="info" />
+ {{ discussion.meta.postings_count }}
+ </dd>
+
+ <dt>{{ $gettext('Aktivitäten') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="activity" :size="15" role="info" />
+ <StudipDateTime :iso="discussion.meta.recent_activity" :relative="true" />
+ </dd>
+
+ <dt>{{ $gettext('Ist geschlossen') }}</dt>
+ <dd
+ v-if="discussion.closed_at"
+ class="inline-flex gap-5 items-center"
+ >
+ <StudipIcon
+ :title="$gettext('Diskussion ist geschlossen')"
+ shape="lock-locked2"
+ :size="15"
+ role="inactive" />
+ </dd>
+ </dl>
+
+ <dl>
+ <dt>{{ $gettext('Teilnehmende') }}</dt>
+ <dd class="nowrap">
+ <ForumMembers :members="discussion.members" :limit="5" />
+ </dd>
+ </dl>
+ </div>
+ <!--mobile display: end-->
+ </td>
+ <td class="nowrap">
+ <ForumMembers :members="discussion.members" :limit="5" />
+ </td>
+ <td>
+ {{ discussion.meta.postings_count }}
+ </td>
+ <td>
+ {{ numberFormatter(discussion.view_count, 1) }}
+ </td>
+ <td class="nowrap">
+ <StudipDateTime v-if="discussion.meta.recent_activity" :iso="discussion.meta.recent_activity" :relative="true" />
+ <template v-else>{{ $gettext('Keine Aktivität') }}</template>
+ </td>
+ <td class="text-center">
+ <StudipIcon
+ v-if="discussion.closed_at"
+ :title="$gettext('Diskussion ist geschlossen')"
+ shape="lock-locked2"
+ :size="20"
+ role="inactive" />
+ </td>
+ <td v-if="withActions" class="actions">
+ <StudipActionMenu
+ :items="getActionMenusItems(discussion)"
+ @edit="editDiscussion(discussion.id)"
+ @delete="deleteDiscussion(discussion.id)"
+ />
+ </td>
+ </tr>
+ <tr v-if="sortedData.length === 0">
+ <td v-if="!forumConfig.isLoading" colspan="7">
+ {{ $gettext('Es sind noch keine Diskussionen vorhanden.') }}
+ </td>
+ </tr>
+ </template>
+ </tbody>
+ <slot name="pagination" />
+ </table>
+</template>
diff --git a/resources/vue/components/forum/discussions/DiscussionTimeline.vue b/resources/vue/components/forum/discussions/DiscussionTimeline.vue
new file mode 100644
index 0000000..c394d01
--- /dev/null
+++ b/resources/vue/components/forum/discussions/DiscussionTimeline.vue
@@ -0,0 +1,88 @@
+<script setup>
+import {computed} from "vue";
+import StudipDateTime from "@/vue/components/StudipDateTime.vue";
+
+const props = defineProps({
+ posts: {
+ type: Array,
+ required: true,
+ },
+ read_index: {
+ type: Number,
+ required: true,
+ default: 0
+ },
+ discussion: {
+ type: Object,
+ required: true,
+ }
+});
+
+const readPosts = computed(() => props.posts.slice(0, props.read_index));
+
+const unreadPosts = computed(() => props.posts.slice(props.read_index));
+
+const readPostsPercentage = computed(() => {
+ if (props.posts.length === 0) {
+ return 100;
+ }
+
+ return parseFloat((props.posts.length - unreadPosts.value.length) * 100 / props.posts.length);
+});
+</script>
+
+<template>
+ <table class="discussion-timeline-table" cellspacing="0">
+ <tbody>
+ <tr>
+ <td></td>
+ <td>
+ <a href="#discussion_start">
+ <StudipDateTime :iso="discussion.mkdate" :relative="true" />
+ <p>1/{{ posts.length }}</p>
+ </a>
+ </td>
+ </tr>
+ <tr v-if="readPostsPercentage > 0" :style="{height: readPostsPercentage+'%' }">
+ <td></td>
+ <td></td>
+ </tr>
+ <template v-if="unreadPosts.length > 0">
+ <tr>
+ <td class="bg-new-activity"></td>
+ <td>
+ <a :href="'#post_'+unreadPosts[0].id">
+ <StudipDateTime :iso="unreadPosts[0].mkdate" :relative="true" />
+ <p>{{ readPosts.length + 1 }}/{{ posts.length }} - {{ $gettext('neu ab hier') }}</p>
+ </a>
+ </td>
+ </tr>
+ <tr :style="{height: (100 - readPostsPercentage)+'%' }">
+ <td class="bg-new-activity"></td>
+ <td></td>
+ </tr>
+ </template>
+ <tr v-if="posts.length > 0">
+ <td class="bg-new-activity"></td>
+ <td>
+ <a :href="'#post_'+posts[posts.length -1].id">
+ <StudipDateTime :iso="posts[posts.length -1].mkdate" :relative="true" />
+ <p>{{ posts.length }}/{{ posts.length }}</p>
+ </a>
+ </td>
+ </tr>
+ <tr v-else>
+ <td></td>
+ <td>
+ <StudipDateTime :iso="discussion.mkdate" :relative="true" />
+ <p>{{ $gettext('Keine Beitrag bis hier') }}</p>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</template>
+<style>
+html {
+ scroll-behavior: smooth;
+}
+</style>
diff --git a/resources/vue/components/forum/discussions/SelectDiscussionType.vue b/resources/vue/components/forum/discussions/SelectDiscussionType.vue
new file mode 100644
index 0000000..c6e71d4
--- /dev/null
+++ b/resources/vue/components/forum/discussions/SelectDiscussionType.vue
@@ -0,0 +1,35 @@
+<script setup>
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import StudipSelect from "@/vue/components/StudipSelect.vue";
+</script>
+
+<template>
+ <StudipSelect
+ v-bind="$attrs"
+ class="multi-select-input"
+ :placeholder="$gettext('Diskussionstyp (Optional)')"
+ label="name"
+ >
+ <template #open-indicator>
+ <StudipIcon shape="arr_1sort" :size="15"/>
+ </template>
+ <template #selected-option="{name, icon}">
+ <div class="flex items-center">
+ <StudipIcon :shape="icon" :size="18" :style="{ marginRight: '8px'}"/>
+ <span class="line-clamp-1 flex-1">{{name}}</span>
+ </div>
+ </template>
+ <template #option="{name, icon}">
+ <div :style="{ display: 'flex', alignItems: 'center' }">
+ <StudipIcon :shape="icon" :size="18" :style="{ marginRight: '8px'}"/>
+ <span :style="{ flex: '1'}" class="line-clamp-1">{{name}}</span>
+ </div>
+ </template>
+ <template #no-options>
+ <div>
+ {{ $gettext('Es gibt keine Diskussionstypen.') }}
+ </div>
+ </template>
+ </StudipSelect>
+</template>
diff --git a/resources/vue/components/forum/enums/SubscriptionNotificationType.ts b/resources/vue/components/forum/enums/SubscriptionNotificationType.ts
new file mode 100644
index 0000000..01deab1
--- /dev/null
+++ b/resources/vue/components/forum/enums/SubscriptionNotificationType.ts
@@ -0,0 +1,5 @@
+export enum SubscriptionNotificationType {
+ All = 'all',
+ RepliesOnly = 'replies_only',
+ None = 'none',
+}
diff --git a/resources/vue/components/forum/helpers/index.js b/resources/vue/components/forum/helpers/index.js
new file mode 100644
index 0000000..2e0b210
--- /dev/null
+++ b/resources/vue/components/forum/helpers/index.js
@@ -0,0 +1,16 @@
+export const highlightText = (keyword, containerSelector)=> {
+ const elements = document.querySelectorAll(containerSelector);
+
+ for (const element of elements) {
+ const re = new RegExp(keyword, "gi");
+ element.innerHTML = element.innerHTML.replace(re, match => `<mark>${match}</mark>`);
+ }
+}
+
+export const removeHighlight = (containerSelector) => {
+ const markedElements = document.querySelectorAll(containerSelector);
+ for (const element of markedElements) {
+ const textNode = document.createTextNode(element.textContent);
+ element.replaceWith(textNode);
+ }
+}
diff --git a/resources/vue/components/forum/helpers/transformers.js b/resources/vue/components/forum/helpers/transformers.js
new file mode 100644
index 0000000..abbe6ae
--- /dev/null
+++ b/resources/vue/components/forum/helpers/transformers.js
@@ -0,0 +1,18 @@
+export const subscriptionTransformer = subscription => {
+ // rename object key
+ if (subscription.subject.title) {
+ subscription.subject.name = subscription.subject.title;
+ }
+
+ return {
+ ...subscription,
+ subject: subscription.subject
+ };
+};
+
+export const topicTransformer = topic => {
+ return {
+ ...topic,
+ ...topic.category
+ }
+};
diff --git a/resources/vue/components/forum/helpers/urls.js b/resources/vue/components/forum/helpers/urls.js
new file mode 100644
index 0000000..eb8538c
--- /dev/null
+++ b/resources/vue/components/forum/helpers/urls.js
@@ -0,0 +1,17 @@
+export const getTopicURL = id => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/topics/show/${id}`);
+export const getTopicEditURL = id => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/topics/edit/${id}`);
+export const getTopicDeleteURL = id => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/topics/delete/${id}`);
+
+// Discussions
+export const getDiscussionCreateURL = () => STUDIP.URLHelper.getURL('dispatch.php/course/forum/discussions/edit');
+export const getDiscussionURL = (discussion_id, params = {}) => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/discussions/show/${discussion_id}`, params);
+
+export const getCategoryURL = id => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/categories/show/${id}`);
+export const getCategoryCreateURL = () => STUDIP.URLHelper.getURL('dispatch.php/course/forum/categories/edit');
+export const getCategoryEditURL = id => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/categories/edit/${id}`);
+export const getCategoryDeleteURL = id => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/categories/delete/${id}`);
+
+export const getSearchURL = (hashtags='') => STUDIP.URLHelper.getURL(`dispatch.php/course/forum/search?${hashtags}`);
+
+
+export const userProfileURL = username => STUDIP.URLHelper.getURL('dispatch.php/profile', {username});
diff --git a/resources/vue/components/forum/posts/Post.vue b/resources/vue/components/forum/posts/Post.vue
new file mode 100644
index 0000000..f954b94
--- /dev/null
+++ b/resources/vue/components/forum/posts/Post.vue
@@ -0,0 +1,235 @@
+<script setup>
+import {computed, ref, useTemplateRef} from "vue";
+import PostEditForm from "./PostEditForm.vue";
+import PostCreateForm from "./PostCreateForm.vue";
+import PostContent from "@/vue/components/forum/posts/PostContent.vue";
+import PostReactions from "./PostReactions.vue";
+import {useForumPost} from "../../../store/pinia/forum/ForumPost";
+import {getDiscussionURL} from "@/vue/components/forum/helpers/urls";
+import StudipDateTime from "@/vue/components/StudipDateTime.vue";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import LinksPreview from "@/vue/components/LinksPreview.vue";
+import UserAvatarDropdown from "../UserAvatarDropdown.vue";
+import {userProfileURL} from "../helpers/urls";
+
+const forumDiscussionPost = useForumPost();
+const props = defineProps({
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ post: {
+ type: Object,
+ required: true,
+ },
+ auth_user: {
+ type: Object,
+ required: true
+ },
+ is_unread: {
+ type: Boolean,
+ default: false
+ }
+});
+
+const postContent = useTemplateRef('postContent');
+const userAvatarContainer = useTemplateRef('userAvatarContainer');
+
+const selectedText = ref('');
+const editPost = ref('');
+const postCreateForm = ref(false);
+
+const isUnread = computed(() => (!props.post.author && props.is_unread) || (props.is_unread && props.post.author.id !== STUDIP.USER_ID))
+
+const copyToClipboard = () => {
+ if (selectedText.value) {
+ navigator.clipboard.writeText(selectedText.value);
+ postContent.value.removeSelection();
+ STUDIP.Report.info($gettext('Der markierte Text wurde in die Zwischenablage kopiert.'));
+ }
+}
+
+const deletePost = async (post) => {
+ STUDIP.Dialog.confirm(
+ $gettext('Wollen Sie diese Beitrag löschen?'),
+ async () => {
+ try {
+ await STUDIP.jsonapi.withPromises().DELETE(`forum-postings/${post.id}`);
+ forumDiscussionPost.removePost(post.id);
+ STUDIP.Report.success($gettext('Der Beitrag wurde gelöscht.'));
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+ },
+ STUDIP.Dialog.close());
+}
+
+const addPost = () => {
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
+ postCreateForm.value = false;
+}
+
+const addReply = post => {
+ postCreateForm.value = true;
+ selectedText.value = post.content;
+}
+
+const forwardPost = post => {
+ let messageBoyd = `
+ ${$gettext('Die Sender:in dieser Nachricht möchte Sie auf den folgenden Beitrag aufmerksam machen: ')}
+ <br />
+ <br />
+ ${$gettext('Link zum Beitrag: ')}
+ <a href="${getDiscussionURL(props.discussion.discussion_id) + '#post_' + post.id}">
+ ${props.discussion.title}
+ </a>
+ `;
+
+ STUDIP.Dialog.fromURL(STUDIP.URLHelper.getURL('dispatch.php/messages/write'), {
+ data: {
+ default_subject: 'WG: ' + props.discussion.title,
+ default_body: STUDIP.wysiwyg.markAsHtml(messageBoyd)
+ },
+ method: 'post'
+ });
+}
+
+const removePostHighlight = id => {
+ const element = document.getElementById(id)
+ if (!element) {
+ console.error("Element not found!")
+ return
+ }
+ element.classList.remove('--highlight')
+}
+</script>
+
+<template>
+ <div :id="'post_'+post.id" class="post" @click="removePostHighlight('post_'+post.id)">
+ <div v-if="isUnread" class="post__unread">
+ </div>
+ <div class="post__body">
+ <div class="post__author">
+ <div class="post__author-avatar" ref="userAvatarContainer">
+ <UserAvatarDropdown
+ v-if="post.author?.id"
+ :user="post.author"
+ size="50px"
+ @update:modelValue="state => {
+ if (state) userAvatarContainer.style.setProperty('z-index', 100);
+ else userAvatarContainer.style.setProperty('z-index', 1);
+ }"
+ />
+ </div>
+ <div class="post__author-name-container --xs">
+ <p v-if="!post.author" class="author-name">
+ {{ $gettext('Unbekannt') }}
+ </p>
+ <p v-else-if="!post.author.id" class="author-name">
+ {{ $gettext('Anonym') }}
+ </p>
+ <a
+ v-else
+ :href="userProfileURL(post.author.username)"
+ :title="$gettext('Zum Profil')"
+ :aria-label="$gettext('Zum Profil von %{name}', { name: post.author.name })"
+ class="author-name"
+ >
+ {{ post.author.name }}
+ </a>
+ <em v-if="post.chdate > post.mkdate">
+ {{ $gettext('Bearbeitet: ') }}
+ <StudipDateTime :iso="post.chdate" :relative="true" />
+ </em>
+ <StudipDateTime v-else :iso="post.mkdate" :relative="true" />
+ </div>
+ </div>
+ <div class="post__content">
+ <div class="post__author-name-container --xl">
+ <p v-if="!post.author" class="author-name">
+ {{ $gettext('Unbekannt') }}
+ </p>
+ <p v-else-if="!post.author.id" class="author-name">
+ {{ $gettext('Anonym') }}
+ </p>
+ <a
+ v-else
+ :href="userProfileURL(post.author.username)"
+ :title="$gettext('Zum Profil')"
+ :aria-label="$gettext('Zum Profil von %{name}', { name: post.author.name })"
+ class="author-name"
+ >
+ {{ post.author.name }}
+ </a>
+ <span v-if="post.chdate > post.mkdate">
+ {{ $gettext('Bearbeitet: ') }}
+ <StudipDateTime :iso="post.chdate" :relative="true" />
+ </span>
+ <StudipDateTime v-else :iso="post.mkdate" :relative="true" />
+ </div>
+ <template v-if="editPost === post.id">
+ <PostEditForm :post="post" :auth_user="auth_user" class="mt-10" @canceled="editPost = ''" @updated="editPost = ''"/>
+ </template>
+ <template v-else>
+ <div class="post__text">
+ <PostContent ref="postContent" v-model="selectedText" :content="post.content" class="forum-quote">
+ <template #actions>
+ <button
+ type="button"
+ v-if="!postCreateForm && !discussion.closed_at"
+ @click="postCreateForm = true; postContent.removeSelection()"
+ :title="$gettext('Auswahl zitieren und antworten')"
+ :aria-label="$gettext('Auswahl zitieren und antworten')"
+ >
+ <StudipIcon shape="quote" :size="20" />
+ </button>
+ <button type="button" @click="copyToClipboard" :title="$gettext('Kopieren')" :aria-label="$gettext('Kopieren')">
+ <StudipIcon shape="clipboard" :size="20" />
+ </button>
+ </template>
+ </PostContent>
+ </div>
+
+ <div v-if="post.meta.opengraph_urls.length" class="opengraph-urls">
+ <LinksPreview :links="post.meta.opengraph_urls" />
+ </div>
+
+ <PostReactions :posting_id="post.id" :reactions="post.reactions" />
+ </template>
+
+ <div class="post__footer">
+ <div></div>
+ <div class="inline-flex items-center gap-40">
+ <div v-if="!discussion.closed_at" class="inline-flex items-center gap-10">
+ <template v-if="post.author?.id === auth_user.id">
+ <button :disabled="editPost === post.id" @click="editPost = post.id" type="button" class="icon-button" :title="$gettext('Beitrag bearbeiten')" :aria-label="$gettext('Beitrag bearbeiten')">
+ <StudipIcon shape="edit" :size="20" aria-hidden="true" />
+ </button>
+ <button @click="deletePost(post)" type="button" class="icon-button" :title="$gettext('Beitrag löschen')" :aria-label="$gettext('Beitrag löschen')">
+ <StudipIcon shape="trash" :size="20" aria-hidden="true" />
+ </button>
+ </template>
+ <button type="button" @click="forwardPost(post)" class="icon-button" :title="$gettext('Beitrage weiterleiten')" :aria-label="$gettext('Beitrage weiterleiten')">
+ <StudipIcon shape="export" :size="20" aria-hidden="true" />
+ </button>
+ <button :disabled="postCreateForm" @click="addReply(post)" type="button" class="icon-button" :title="$gettext('Zitieren und antworten')" :aria-label="$gettext('Zitieren und Antworten')">
+ <StudipIcon shape="quote" :size="20" aria-hidden="true" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div v-if="postCreateForm && !discussion.closed_at" class="post-form-container">
+ <PostCreateForm
+ :parent_id="post.id"
+ :discussion_id="props.discussion.discussion_id"
+ :auth_user="auth_user"
+ v-model:quote="selectedText"
+ @canceled="postCreateForm = false; selectedText = ''"
+ @created="addPost"
+ />
+ </div>
+</template>
diff --git a/resources/vue/components/forum/posts/PostContent.vue b/resources/vue/components/forum/posts/PostContent.vue
new file mode 100644
index 0000000..93111f3
--- /dev/null
+++ b/resources/vue/components/forum/posts/PostContent.vue
@@ -0,0 +1,66 @@
+<script setup>
+import {onDeactivated, onMounted, useTemplateRef, watch} from "vue";
+
+const emit = defineEmits(['update:modelValue']);
+const props = defineProps({
+ content: {
+ type: String,
+ default: ''
+ },
+ modelValue: {
+ type: String,
+ default: ''
+ }
+});
+
+const actionsRef = useTemplateRef('actions');
+
+const onTextSelected = event => {
+ if (document.getSelection().toString()) {
+ emit('update:modelValue', document.getSelection().toString());
+ actionsRef.value.style.display = 'inline-flex';
+ actionsRef.value.style.top = event.pageY +'px';
+ actionsRef.value.style.left = event.pageX+'px';
+ }
+}
+
+const newSelectionHandler = () => {
+ if(! document.getSelection().toString()) {
+ actionsRef.value.style.display = 'none';
+ }
+}
+
+const removeSelection = () => {
+ actionsRef.value.style.display = 'none';
+ document.getSelection().removeAllRanges();
+}
+
+defineExpose({
+ removeSelection
+});
+
+onMounted(() => {
+ document.addEventListener("selectionchange", newSelectionHandler);
+})
+
+onDeactivated(() => {
+ document.removeEventListener('selectionchange', newSelectionHandler);
+});
+
+watch(() => props.modelValue, newValue => {
+ if (!newValue) {
+ removeSelection();
+ }
+});
+</script>
+
+
+<template>
+ <div @mouseup="onTextSelected" class="with-ballon-action" v-bind="$attrs">
+ <p class="text-highlight m-0 post-content" v-html="content"></p>
+
+ <div class="ballon-action" ref="actions">
+ <slot name="actions"></slot>
+ </div>
+ </div>
+</template>
diff --git a/resources/vue/components/forum/posts/PostCreateForm.vue b/resources/vue/components/forum/posts/PostCreateForm.vue
new file mode 100644
index 0000000..095413a
--- /dev/null
+++ b/resources/vue/components/forum/posts/PostCreateForm.vue
@@ -0,0 +1,161 @@
+<script setup>
+import {onMounted, onUnmounted, ref} from "vue";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import {useForumPost} from "../../../store/pinia/forum/ForumPost";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import StudipSwitch from "@/vue/components/StudipSwitch.vue";
+import StudipWysiwyg from "@/vue/components/StudipWysiwyg.vue";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import {userProfileURL} from "../helpers/urls";
+
+const forumConfig = useForumConfig();
+const forumDiscussionPost = useForumPost();
+const emit = defineEmits(['canceled', 'created', 'update:quote']);
+const props = defineProps({
+ discussion_id: {
+ type: String,
+ required: true,
+ },
+ auth_user: {
+ type: Object,
+ required: true,
+ },
+ quote: {
+ type: String,
+ },
+ parent_id: {
+ type: String,
+ }
+});
+
+const normalizeQuote = quote => {
+ const parser = new DOMParser();
+ const document = parser.parseFromString(quote, 'text/html');
+
+ const blockquotes = document.querySelectorAll('blockquote');
+ blockquotes.forEach(bq => {
+ const replacement = document.createElement('div');
+ replacement.innerHTML = '[...]<br />';
+ bq.parentNode.replaceChild(replacement, bq);
+ });
+
+ return document.body.innerHTML;
+}
+
+onMounted(() => {
+ if (window.location.hash) {
+ window.history.pushState('', document.title, window.location.href.split('#')[0]);
+ }
+
+ if (props.quote) {
+ content.value = `
+ <a href="#post_${props.parent_id}">
+ <blockquote>${normalizeQuote(props.quote)}</blockquote>
+ </a>
+ <br />
+ `;
+ }
+})
+
+onUnmounted(() => {
+ if (window.location.hash) {
+ window.history.pushState('', document.title, window.location.href.split('#')[0]);
+ }
+})
+
+const content = ref('');
+const anonymous = ref(false);
+const isLoading = ref(false);
+
+const getPostJSONAPIObject = () => ({
+ data: {
+ type: 'forum-postings',
+ attributes: {
+ content: content.value,
+ anonymous: anonymous.value,
+ },
+ relationships: {
+ discussion: {
+ data: {
+ type: 'forum-discussions',
+ id: props.discussion_id
+ }
+ },
+ posting: {
+ data: {
+ type: 'forum-postings',
+ id: props.parent_id
+ }
+ }
+ }
+ }
+})
+
+const storePost = async () => {
+ try {
+ isLoading.value = true;
+
+ const response = await STUDIP.jsonapi.withPromises().POST(
+ 'forum-postings?include=author,opengraph-urls,posting,reactions,reactions.user&fields[users]=id',
+ { data: getPostJSONAPIObject }
+ );
+
+ const post = await deserializeJSONAPIResponse(response)
+
+ forumDiscussionPost.addPost(post);
+ content.value = "";
+ emit("created", post);
+
+ STUDIP.Report.success($gettext("Der Beitrag wurde gespeichert."));
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+</script>
+
+<template>
+ <form @submit.prevent="storePost" class="default post-form forum-quote">
+ <div class="post-form__author">
+ <a
+ :href="userProfileURL(auth_user.username)"
+ class="post-form__author-image profile-image-container"
+ :title="$gettext('Zum Profil')"
+ :aria-label="$gettext('Zum Profil von %{name}', { name: auth_user.name })"
+ >
+ <img :src="auth_user.avatar_url" :alt="auth_user.name" />
+ </a>
+ <p class="post-form__author-name">{{ auth_user.name }}</p>
+ </div>
+ <StudipWysiwyg :required="true" v-model="content" />
+ <div v-if="forumConfig.anonymousPost" class="mt-10">
+ <StudipSwitch name="anonymous" v-model="anonymous" :label="$gettext('Anonym')" />
+ </div>
+ <div class="flex items-center gap-10">
+ <button
+ type="submit"
+ :disabled="isLoading || !content"
+ class="button --with-icon"
+ :title="$gettext('Antworten')"
+ :aria-label="$gettext('Antworten')"
+ >
+ <StudipIcon shape="reply" :size="20" class="icon-default" aria-hidden="true" />
+ <StudipIcon shape="reply" :size="20" class="icon-hover" role="info_alt" aria-hidden="true" />
+ {{ $gettext('Antworten') }}
+ </button>
+ <button
+ type="button"
+ class="button --with-icon"
+ :title="$gettext('Abbrechen')"
+ :aria-label="$gettext('Abbrechen')"
+ @click="$emit('canceled')"
+ >
+ <StudipIcon shape="decline" :size="20" class="icon-default" aria-hidden="true"/>
+ <StudipIcon shape="decline" :size="20" class="icon-hover" role="info_alt" aria-hidden="true"/>
+ {{ $gettext('Abbrechen') }}
+ </button>
+ </div>
+ </form>
+</template>
diff --git a/resources/vue/components/forum/posts/PostEditForm.vue b/resources/vue/components/forum/posts/PostEditForm.vue
new file mode 100644
index 0000000..9c50f90
--- /dev/null
+++ b/resources/vue/components/forum/posts/PostEditForm.vue
@@ -0,0 +1,107 @@
+<script setup>
+import {onMounted, onUnmounted, ref} from "vue";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import {useForumPost} from "../../../store/pinia/forum/ForumPost";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import StudipSwitch from "@/vue/components/StudipSwitch.vue";
+import StudipWysiwyg from "@/vue/components/StudipWysiwyg.vue";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+
+const forumDiscussionPost = useForumPost();
+const forumConfig = useForumConfig();
+const emit = defineEmits(['canceled', 'updated']);
+const props = defineProps({
+ auth_user: {
+ type: Object,
+ required: true,
+ },
+ post: {
+ type: Object,
+ required: true,
+ }
+});
+
+const anonymous = ref(props.post.anonymous);
+const content = ref(props.post.content);
+const isLoading = ref(false);
+
+const getPostJSONAPIObject = () => ({
+ data: {
+ id: props.post.id,
+ type: 'forum-postings',
+ attributes: {
+ content: content.value,
+ anonymous: anonymous.value
+ }
+ }
+})
+
+
+const updatePost = async () => {
+ try {
+ isLoading.value = true;
+
+ const response = await STUDIP.jsonapi.withPromises().PATCH(
+ `forum-postings/${props.post.id}?include=author,opengraph-urls,posting,reactions,reactions.user&fields[users]=id`,
+ { data: getPostJSONAPIObject }
+ );
+
+ const post = await deserializeJSONAPIResponse(response)
+
+ forumDiscussionPost.updatePost(post);
+ content.value = "";
+ emit("updated", post);
+
+ STUDIP.Report.success($gettext("Die Änderungen wurde gespeichert."));
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ } finally {
+ isLoading.value = false;
+ }
+}
+
+onMounted(() => {
+ if (window.location.hash) {
+ window.history.pushState('', document.title, window.location.href.split('#')[0]);
+ }
+})
+
+onUnmounted(() => {
+ if (window.location.hash) {
+ window.history.pushState('', document.title, window.location.href.split('#')[0]);
+ }
+})
+</script>
+
+<template>
+ <form @submit.prevent="updatePost" class="default post-form forum-quote">
+ <StudipWysiwyg required="required" v-model="content" />
+ <div v-if="forumConfig.anonymousPost" class="mt-10">
+ <StudipSwitch name="anonymous" v-model="anonymous" :label="$gettext('Anonym')" />
+ </div>
+ <div class="flex items-center gap-10">
+ <button
+ type="submit"
+ :disabled="isLoading || !content"
+ class="button --with-icon"
+ :value="$gettext('Speichern')"
+ :title="$gettext('Speichern')"
+ >
+ <StudipIcon shape="reply" :size="20" class="icon-default" aria-hidden="true" />
+ <StudipIcon shape="reply" :size="20" class="icon-hover" role="info_alt" aria-hidden="true" />
+ {{ $gettext('Speichern') }}
+ </button>
+ <button
+ type="button"
+ class="button --with-icon"
+ :title="$gettext('Abbrechen')"
+ @click="$emit('canceled')"
+ >
+ <StudipIcon shape="decline" :size="20" class="icon-default" aria-hidden="true"/>
+ <StudipIcon shape="decline" :size="20" class="icon-hover" role="info_alt" aria-hidden="true"/>
+ {{ $gettext('Abbrechen') }}
+ </button>
+ </div>
+ </form>
+</template>
diff --git a/resources/vue/components/forum/posts/PostReactions.vue b/resources/vue/components/forum/posts/PostReactions.vue
new file mode 100644
index 0000000..816ed15
--- /dev/null
+++ b/resources/vue/components/forum/posts/PostReactions.vue
@@ -0,0 +1,141 @@
+<script setup>
+import {computed, ref, useTemplateRef} from "vue";
+import {REACTION_ICONS} from "./reactions";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import {numberFormatter} from "../../../../assets/javascripts/lib/number_formatter";
+import useDetectOutsideClick from "../../../composables/useDetectOutsideClick";
+import {useForumPost} from "../../../store/pinia/forum/ForumPost";
+import {deserializeJSONAPIResponse} from "../../../../assets/javascripts/lib/jsonapiUtils";
+import StudipIcon from "../../StudipIcon.vue";
+
+const forumDiscussionPost = useForumPost();
+const props = defineProps({
+ posting_id: {
+ type: String,
+ required: true,
+ },
+ reactions: {
+ type: Array,
+ required: true
+ }
+});
+
+const showReactions = ref(false);
+const reactionStatusMessage = ref(null);
+
+const groupedReactions = computed(() => Object.groupBy(props.reactions, ({ emoji }) => emoji));
+
+const announceToScreenReader = message => reactionStatusMessage.value.textContent = message;
+
+const getPostReactionJSONAPIObject = (emoji) => ({
+ data: {
+ type: 'forum-posting-reactions',
+ attributes: {
+ emoji: emoji
+ },
+ meta: {
+ 'emoji-icon': REACTION_ICONS[emoji].icon
+ },
+ relationships: {
+ posting: {
+ data: {
+ type: 'forum-postings',
+ id: props.posting_id
+ }
+ }
+ }
+ }
+})
+
+const storeReaction = async (emoji) => {
+ try {
+ const response = await STUDIP.jsonapi.withPromises().POST(
+ 'forum-posting-reactions?include=user&fields[users]=id',
+ { data: getPostReactionJSONAPIObject(emoji) }
+ );
+
+ const reaction = await deserializeJSONAPIResponse(response);
+ forumDiscussionPost.addPostReaction(reaction, props.posting_id);
+ showReactions.value = false;
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+}
+
+const deleteReaction = async (reactionId) => {
+ try {
+ await STUDIP.jsonapi.withPromises().DELETE(`forum-posting-reactions/${reactionId}`);
+ forumDiscussionPost.removePostReaction(reactionId, props.posting_id);
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+}
+
+const toggleReaction = async (emoji, reactions = props.reactions) => {
+ const userReaction = findUserReaction(emoji, reactions);
+
+ if (userReaction) {
+ await deleteReaction(userReaction.id);
+ announceToScreenReader($gettext('Reaktion wurde entfernt.'));
+ } else {
+ await storeReaction(emoji);
+ announceToScreenReader($gettext('Reaktion wurde hinzugefügt.'));
+ }
+}
+
+const findUserReaction = (emoji, reactions = props.reactions) => reactions.find(reaction => reaction.user.id === STUDIP.USER_ID && reaction.emoji === emoji);
+
+const reactionCreate = useTemplateRef('reactionCreate');
+useDetectOutsideClick(reactionCreate, () => showReactions.value = false);
+</script>
+
+<template>
+ <div class="post-reactions-container">
+ <div aria-live="polite" class="sr-only" role="status" ref="reactionStatusMessage"></div>
+
+ <template v-if="reactions.length">
+ <template v-for="(reaction, emoji) in groupedReactions" :key="emoji">
+ <button
+ type="button"
+ class="post-reaction"
+ :class="{
+ '--active': findUserReaction(emoji, reaction)
+ }"
+ :title="findUserReaction(emoji, reaction) ? $gettext('Reaktion zurücknehmen') : $gettext('Reaktion hinzufügen')"
+ :aria-label="findUserReaction(emoji, reaction) ? $gettext('Reaktion zurücknehmen') : $gettext('Reaktion hinzufügen')"
+ @click="toggleReaction(emoji, reaction)">
+ <span class="html-emoji" v-html="REACTION_ICONS[emoji].icon"></span>
+ <span>{{ numberFormatter(reaction.length, 1) }}</span>
+ </button>
+ </template>
+ </template>
+ <div ref="reactionCreate" class="post-reactions">
+ <button
+ class="post-reactions__create-button"
+ type="button"
+ :title="$gettext('Reagieren')"
+ :aria-label="$gettext('Reagieren')"
+ @click="showReactions = !showReactions">
+ <StudipIcon shape="add-reaction" class="add-reaction-icon" :size="18" />
+ <p>{{ numberFormatter(reactions.length, 1) }}</p>
+ </button>
+ <Transition name="fade">
+ <div v-if="showReactions" class="post-reactions__container">
+ <template v-for="(emoji, index) in REACTION_ICONS" :key="index">
+ <button
+ type="button"
+ :class="{
+ '--active': findUserReaction(emoji.value)
+ }"
+ :title="$gettext('Auf diesen Beitrag reagieren')"
+ :aria-label="$gettext('Auf diesen Beitrag mit %{emojiName} reagieren', { emojiName: emoji.value })"
+ @click="toggleReaction(emoji.value)"
+ >
+ <span class="html-emoji" v-html="emoji.icon"></span>
+ </button>
+ </template>
+ </div>
+ </Transition>
+ </div>
+ </div>
+</template>
diff --git a/resources/vue/components/forum/posts/reactions.js b/resources/vue/components/forum/posts/reactions.js
new file mode 100644
index 0000000..805bf55
--- /dev/null
+++ b/resources/vue/components/forum/posts/reactions.js
@@ -0,0 +1,35 @@
+export const REACTION_ICONS = {
+ 'THUMBS UP SIGN' : {
+ icon: '&#128077;',
+ value: 'THUMBS UP SIGN'
+ },
+ 'THUMBS DOWN SIGN' : {
+ icon: '&#128078;',
+ value: 'THUMBS DOWN SIGN'
+ },
+ 'ROCKET' : {
+ icon: '&#128640;',
+ value: 'ROCKET'
+ },
+ 'GRINNING FACE': {
+ icon: '&#128512;',
+ value: 'GRINNING FACE'
+ },
+ 'SMILING FACE WITH SUNGLASSES': {
+ icon: '&#128526;',
+ value: 'SMILING FACE WITH SUNGLASSES'
+ },
+ 'CONFUSED FACE': {
+ icon: '&#128533;',
+ value: 'CONFUSED FACE'
+ },
+ 'BLACK HEART SUIT': {
+ icon: '&#x2665;',
+ value: 'BLACK HEART SUIT'
+ },
+ 'PARTY POPPER': {
+ icon: '&#127881;',
+ value: 'PARTY POPPER'
+ }
+}
+
diff --git a/resources/vue/components/forum/topics/CreateTopic.vue b/resources/vue/components/forum/topics/CreateTopic.vue
new file mode 100644
index 0000000..d6d3d5f
--- /dev/null
+++ b/resources/vue/components/forum/topics/CreateTopic.vue
@@ -0,0 +1,37 @@
+
+<script setup>
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import {computed} from "vue";
+
+const props = defineProps({
+ category_id: {
+ type: String,
+ },
+ label: {
+ type: String,
+ default: ''
+ }
+});
+
+const topicCreateURL = computed(() => {
+ if (props.category_id) {
+ return STUDIP.URLHelper.getURL(`dispatch.php/course/forum/topics/edit?category_id=${props.category_id}`);
+ }
+
+ return STUDIP.URLHelper.getURL('dispatch.php/course/forum/topics/edit');
+});
+</script>
+
+<template>
+ <a
+ :href="topicCreateURL"
+ data-dialog="width=700"
+ :title="$gettext('Neues Thema anlegen')"
+ :aria-label="$gettext('Neues Thema anlegen')"
+ class="icon-button"
+ role="button"
+ >
+ <StudipIcon shape="add" :size="20" aria-hidden="true" />
+ <span v-if="label" class="label">{{ label }}</span>
+ </a>
+</template>
diff --git a/resources/vue/components/forum/topics/SelectTopicInput.vue b/resources/vue/components/forum/topics/SelectTopicInput.vue
new file mode 100644
index 0000000..7dabc95
--- /dev/null
+++ b/resources/vue/components/forum/topics/SelectTopicInput.vue
@@ -0,0 +1,53 @@
+<script setup>
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import StudipSelect from "@/vue/components/StudipSelect.vue";
+
+const selectedTopics = defineModel();
+</script>
+
+<template>
+ <StudipSelect
+ v-bind="$attrs"
+ class="multi-select-input"
+ :placeholder="$gettext('Thema')"
+ label="name"
+ v-model="selectedTopics"
+ :reduce="(topic) => {
+ if(topic.name) {
+ return topic;
+ }
+
+ return { name: topic };
+ }"
+ >
+ <template #search="{attributes, events}">
+ <input
+ class="vs__search"
+ :required="!selectedTopics"
+ v-bind="attributes"
+ v-on="events"
+ />
+ </template>
+ <template #open-indicator>
+ <StudipIcon shape="add" :size="15"/>
+ </template>
+ <template #selected-option="{name, color}">
+ <div class="flex items-center">
+ <span v-if="color" :style="{ backgroundColor: color, height: '14px', width: '14px', marginRight: '8px'}"></span>
+ <span class="line-clamp-1 flex-1">{{ name }}</span>
+ </div>
+ </template>
+ <template #option="{name, color}">
+ <div :style="{ display: 'flex', alignItems: 'center' }">
+ <span v-if="color" :style="{ backgroundColor: color, height: '14px', width: '14px', marginRight: '8px'}"></span>
+ <span :style="{ flex: '1'}" class="line-clamp-1">{{ name }}</span>
+ </div>
+ </template>
+ <template #no-options>
+ <div>
+ {{ $gettext('Es gibt keine Themen.') }}
+ </div>
+ </template>
+ </StudipSelect>
+</template>
diff --git a/resources/vue/components/forum/topics/TopicItem.vue b/resources/vue/components/forum/topics/TopicItem.vue
new file mode 100644
index 0000000..2fdfe74
--- /dev/null
+++ b/resources/vue/components/forum/topics/TopicItem.vue
@@ -0,0 +1,203 @@
+<script setup>
+import {getTopicDeleteURL, getTopicEditURL, getTopicURL} from "../helpers/urls";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import StudipActionMenu from "@/vue/components/StudipActionMenu.vue";
+import StudipIcon from "@/vue/components/StudipIcon.vue";
+import StudipDateTime from "@/vue/components/StudipDateTime.vue";
+import {computed} from "vue";
+
+const forumConfig = useForumConfig();
+
+const props = defineProps({
+ topic: {
+ type: Object,
+ required: true,
+ },
+ renderType: {
+ type: String,
+ default: 'card'
+ }
+});
+
+const topicActionMenus = computed(() => {
+ if (forumConfig.isModerator) {
+ return [
+ { label: $gettext('Thema bearbeiten'), icon: 'edit', emit: 'edit'},
+ { label: $gettext('Thema löschen'), icon: 'trash', emit: 'delete'}
+ ];
+ }
+
+ return [];
+});
+
+const editTopic = () => STUDIP.Dialog.fromURL(getTopicEditURL(props.topic.id),{ width: '700' });
+
+const deleteTopic = () => STUDIP.Dialog.confirm(
+ $gettext('Wollen Sie dieses "%{name}" Thema löschen? Dann werden auch alle Diskussionen gelöscht!', {name: props.topic.name}),
+ () => window.location = getTopicDeleteURL(props.topic.id),
+ STUDIP.Dialog.close()
+);
+</script>
+
+<template>
+ <tr v-if="renderType === 'tr'">
+ <td>
+ <div class="topic-overview">
+ <div class="content">
+ <div class="title-with-actions">
+ <div class="title-with-actions__content">
+ <a class="title-with-actions__link" :href="getTopicURL(topic.id)" :title="$gettext('Zum Thema')">
+ <h3 class="line-clamp-2">{{ topic.name }}</h3>
+ <span
+ v-if="topic.meta.postings_count > topic.meta.user_read_index"
+ class="unread-items-badge"
+ role="status"
+ aria-live="polite"
+ :aria-label="$gettext('Sie haben %{count} ungelesene Beiträge', {count: topic.meta.postings_count - topic.meta.user_read_index})"
+ :title="$gettext('Sie haben %{count} ungelesene Beiträge', {count: topic.meta.postings_count - topic.meta.user_read_index})"
+ >
+ {{ topic.meta.postings_count - topic.meta.user_read_index }}
+ </span>
+ </a>
+ </div>
+
+ <div class="title-with-actions__actions-xs">
+ <StudipActionMenu
+ :items="topicActionMenus"
+ @edit="editTopic"
+ @delete="deleteTopic"
+ />
+ </div>
+ </div>
+ <p>
+ <small class="line-clamp-3">{{ topic.description }}</small>
+ </p>
+ </div>
+ </div>
+
+ <!--mobile display: start-->
+ <div class="details-xs">
+ <dl>
+ <dt>{{ $gettext('Anzahl der Teilnehmenden am Thema') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="community2" role="info" :size="15" aria-hidden="true"/>
+ {{ topic.meta.users_count }}
+ </dd>
+ </dl>
+
+ <dl>
+ <dt>{{ $gettext('Anzahl der Diskussionen') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="forum" role="info" :size="15" aria-hidden="true"/>
+ {{ topic.meta.discussions_count }}
+ </dd>
+
+ <dt>{{ $gettext('Anzahl der Beiträge') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="reply" role="info" :size="15" aria-hidden="true"/>
+ {{ topic.meta.postings_count }}
+ </dd>
+
+ <dt>{{ $gettext('Aktivitäten') }}</dt>
+ <dd class="inline-flex gap-5 items-center">
+ <StudipIcon shape="activity" role="info" :size="15" aria-hidden="true"/>
+ <StudipDateTime v-if="topic.meta.recent_activity" :iso="topic.meta.recent_activity" :relative="true" />
+ <template v-else>{{ $gettext('Keine Aktivität') }}</template>
+ </dd>
+ </dl>
+ </div>
+ <!--mobile display: end-->
+ </td>
+ <td class="nowrap" :title="$gettext('Anzahl der Diskussionen')" :aria-label="$gettext('Anzahl der Diskussionen')">
+ {{ topic.meta.discussions_count }} {{ $gettext('Diskussionen') }}
+ </td>
+ <td>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Teilnehmenden am Thema')" :aria-label="$gettext('Anzahl der Teilnehmenden am Thema')" role="group">
+ <StudipIcon shape="community2" role="info" :size="20" aria-hidden="true" />
+ <span>{{ topic.meta.users_count }}</span>
+ </span>
+ </td>
+ <td>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Beiträge')" :aria-label="$gettext('Anzahl der Beiträge')" role="group">
+ <StudipIcon shape="reply" role="info" :size="20" aria-hidden="true" />
+ <span>{{ topic.meta.postings_count }}</span>
+ </span>
+ </td>
+ <td>
+ <span class="inline-flex gap-10 items-center nowrap" :title="$gettext('Letzte Aktivität')" :aria-label="$gettext('Letzte Aktivität')" role="group">
+ <StudipIcon shape="activity" role="info" :size="20" aria-hidden="true"/>
+ <StudipDateTime v-if="topic.meta.recent_activity" :iso="topic.meta.recent_activity" :relative="true" />
+ <template v-else>{{ $gettext('Keine Aktivität') }}</template>
+ </span>
+ </td>
+ <td class="actions">
+ <StudipActionMenu
+ :items="topicActionMenus"
+ @edit="editTopic"
+ @delete="deleteTopic"
+ />
+ </td>
+ </tr>
+ <a
+ v-else
+ :href="getTopicURL(topic.id)"
+ :title="$gettext('Zum Thema')"
+ class="styleless"
+ >
+ <div class="topic-card">
+ <div class="topic-card__content">
+ <div class="topic-card__body">
+ <div class="flex space-between">
+ <div class="flex items-start gap-10">
+ <h3 class="topic-card__title line-clamp-2">
+ {{ topic.name }}
+ </h3>
+
+ <span
+ v-if="topic.meta.postings_count > topic.meta.user_read_index"
+ class="unread-items-badge"
+ role="status"
+ aria-live="polite"
+ :aria-label="$gettext('Sie haben %{count} ungelesene Beiträge', {count: topic.meta.postings_count - topic.meta.user_read_index})"
+ :title="$gettext('Sie haben %{count} ungelesene Beiträge', {count: topic.meta.postings_count - topic.meta.user_read_index})"
+ >
+ {{ topic.meta.postings_count - topic.meta.user_read_index }}
+ </span>
+ </div>
+
+ <div class="actions">
+ <StudipActionMenu
+ :items="topicActionMenus"
+ @edit="editTopic"
+ @delete="deleteTopic"
+ />
+ </div>
+ </div>
+ <p>
+ <small class="line-clamp-3">{{ topic.description }}</small>
+ </p>
+ </div>
+ <div class="topic-card__footer">
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Teilnehmenden am Thema')" :aria-label="$gettext('Anzahl der Teilnehmenden am Thema')" role="group">
+ <StudipIcon shape="community2" role="info" :size="15" aria-hidden="true" />
+ <small>{{ topic.meta.users_count }}</small>
+ </span>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Anzahl der Beiträge')" :aria-label="$gettext('Anzahl der Beiträge')" role="group">
+ <StudipIcon shape="reply" role="info" :size="15" aria-hidden="true" />
+ <small>{{ topic.meta.postings_count }}</small>
+ </span>
+ <span class="inline-flex gap-10 items-center" :title="$gettext('Letzte Aktivität')" :aria-label="$gettext('Letzte Aktivität')" role="group">
+ <StudipIcon shape="activity" role="info" :size="15" aria-hidden="true" />
+ <small v-if="topic.meta.recent_activity">
+ <StudipDateTime :iso="topic.meta.recent_activity" :relative="true" />
+ </small>
+ <small v-else>
+ {{ $gettext('Keine Aktivität') }}
+ </small>
+ </span>
+ </div>
+ </div>
+ </div>
+ </a>
+</template>
diff --git a/resources/vue/components/forum/topics/TopicsIndex.vue b/resources/vue/components/forum/topics/TopicsIndex.vue
new file mode 100644
index 0000000..598d691
--- /dev/null
+++ b/resources/vue/components/forum/topics/TopicsIndex.vue
@@ -0,0 +1,228 @@
+<script setup>
+import draggable from "vuedraggable";
+import {toRef} from "vue";
+import CreateTopic from "./CreateTopic.vue";
+import TopicItem from "./TopicItem.vue";
+import Loader from "../Loader.vue";
+import {useForumConfig} from "../../../store/pinia/forum/ForumConfig";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+import EmptyForum from "../EmptyForum.vue";
+import CategoryItem from "../categories/CategoryItem.vue";
+import {useSortable} from "../../../composables/useSortable";
+
+const forumConfig = useForumConfig();
+
+const props = defineProps({
+ topics: {
+ type: Array,
+ required: true
+ },
+ showEmptyForumLayout: {
+ type: Boolean,
+ default: false
+ },
+ isLoading: {
+ type: Boolean,
+ default: false
+ },
+ categoryId: {
+ type: String
+ }
+});
+
+const topicsRef = toRef(props, 'topics');
+
+const {
+ sortedData: sortedTopics,
+ sortBy,
+ getSortClass,
+ getAriaSortString,
+ getAriaSortLabel
+} = useSortable(topicsRef);
+
+const updateTopicsOrder = async () => {
+ try {
+ const topicIds = sortedTopics.value.map(({ id }) => id);
+
+ const data = {
+ attributes: {
+ 'topic-ids': topicIds
+ },
+ relationships: {
+ range: {
+ data: {
+ type: 'courses',
+ id: STUDIP.URLHelper.parameters.cid
+ }
+ }
+ }
+ };
+
+ await STUDIP.jsonapi.withPromises().PATCH(
+ 'forum-topics/sort',
+ { data: { data } }
+ );
+ } catch (error) {
+ STUDIP.Report.error(error.statusText);
+ }
+}
+</script>
+
+<template>
+ <Loader v-if="isLoading" />
+ <template v-else>
+ <template v-if="sortedTopics.length || !showEmptyForumLayout">
+ <div v-if="forumConfig.tileLayout">
+ <draggable
+ v-if="sortedTopics.length"
+ v-model="sortedTopics"
+ item-key="topic_id"
+ :animation="200"
+ @end="updateTopicsOrder"
+ :disabled="!forumConfig.isModerator"
+ class="topic-cards-container"
+ :class="{
+ '--fill-free-space': sortedTopics.length > 1
+ }"
+ tag="ul">
+ <template #item="{element}">
+ <li>
+ <CategoryItem v-if="element.category" :category="element.category" />
+ <TopicItem v-else :topic="element" />
+ </li>
+ </template>
+ <template v-if="forumConfig.isModerator" #footer>
+ <li key="footer">
+ <div class="topic-card --new-topic">
+ <CreateTopic
+ class="--with-label"
+ :category_id="categoryId"
+ :label="$gettext('Neues Thema anlegen')"
+ />
+ </div>
+ </li>
+ </template>
+ </draggable>
+ <div v-else-if="forumConfig.isModerator" class="topic-cards-container">
+ <div class="topic-card --new-topic">
+ <CreateTopic
+ :category_id="categoryId"
+ class="--with-label"
+ :label="$gettext('Neues Thema anlegen')"
+ />
+ </div>
+ </div>
+ </div>
+ <table v-else class="default forum-table --topics-index">
+ <colgroup>
+ <col>
+ <col style="width: 15%;">
+ <col style="width: 15%;">
+ <col style="width: 15%;">
+ <col style="width: 10%;">
+ <col style="width: 5%">
+ </colgroup>
+ <thead>
+ <tr class="sortable">
+ <th
+ :class="getSortClass('name')"
+ :aria-sort="getAriaSortString('name')"
+ :aria-label="getAriaSortLabel('name', $gettext('Name'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('name')"
+ :title="$gettext('Nach Name sortieren')">
+ {{ $gettext('Name') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.discussions_count')"
+ :aria-sort="getAriaSortString('meta.discussions_count')"
+ :aria-label="getAriaSortLabel('meta.discussions_count', $gettext('Anzahl der Diskussionen'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.discussions_count')"
+ :title="$gettext('Nach Anzahl der Diskussionen sortieren')">
+ {{ $gettext('Diskussionen') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.users_count')"
+ :aria-sort="getAriaSortString('meta.users_count')"
+ :aria-label="getAriaSortLabel('meta.users_count', $gettext('Anzahl der Teilnehmenden'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.users_count')"
+ :title="$gettext('Nach Anzahl der Teilnehmenden sortieren')">
+ {{ $gettext('Teilnehmende') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.postings_count')"
+ :aria-sort="getAriaSortString('meta.postings_count')"
+ :aria-label="getAriaSortLabel('meta.postings_count', $gettext('Anzahl der Beiträge'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.postings_count')"
+ :title="$gettext('Nach Anzahl der Beiträge sortieren')">
+ {{ $gettext('Beiträge') }}
+ </a>
+ </th>
+ <th
+ :class="getSortClass('meta.recent_activity')"
+ :aria-sort="getAriaSortString('meta.recent_activity')"
+ :aria-label="getAriaSortLabel('meta.recent_activity', $gettext('Aktivitäten'))"
+ >
+ <a
+ href="#"
+ @click.prevent="sortBy('meta.recent_activity')"
+ :title="$gettext('Nach Aktivitäten sortieren')">
+ {{ $gettext('Aktivitäten') }}
+ </a>
+ </th>
+ <th></th>
+ </tr>
+ </thead>
+ <draggable
+ v-if="sortedTopics.length"
+ v-model="sortedTopics"
+ item-key="topic_id"
+ :animation="200"
+ @end="updateTopicsOrder"
+ :disabled="!forumConfig.isModerator"
+ tag="tbody">
+ <template #item="{element}">
+ <CategoryItem v-if="element.category" :category="element.category" render-type="tr" />
+ <TopicItem v-else :topic="element" render-type="tr" />
+ </template>
+ </draggable>
+ <tbody v-else>
+ <tr>
+ <td colspan="6">
+ {{ $gettext('Es sind noch keine Themen vorhanden.') }}
+ </td>
+ </tr>
+ </tbody>
+ <tfoot v-if="forumConfig.isModerator">
+ <tr class="new-topic">
+ <td colspan="6">
+ <div class="footer-actions-container">
+ <CreateTopic
+ :category_id="categoryId"
+ class="--with-label"
+ :label="$gettext('Neues Thema anlegen')"
+ />
+ </div>
+ </td>
+ </tr>
+ </tfoot>
+ </table>
+ <slot name="pagination" />
+ </template>
+ <EmptyForum v-else-if="showEmptyForumLayout" />
+ </template>
+</template>
diff --git a/resources/vue/composables/useDetectOutsideClick.js b/resources/vue/composables/useDetectOutsideClick.js
new file mode 100644
index 0000000..ee7db14
--- /dev/null
+++ b/resources/vue/composables/useDetectOutsideClick.js
@@ -0,0 +1,18 @@
+import {onBeforeUnmount, onMounted} from "vue";
+
+export default function useDetectOutsideClick(component, callback) {
+ if (!component) return;
+ function listener(event) {
+ if (event.target !== component.value && event.composedPath().includes(component.value)) {
+ return;
+ }
+ if (typeof callback === 'function') {
+ callback();
+ }
+ }
+
+ onMounted(() => { window.addEventListener('click', listener) });
+ onBeforeUnmount(() => { window.removeEventListener('click', listener) });
+
+ return {listener};
+}
diff --git a/resources/vue/composables/useSortable.js b/resources/vue/composables/useSortable.js
new file mode 100644
index 0000000..5533076
--- /dev/null
+++ b/resources/vue/composables/useSortable.js
@@ -0,0 +1,72 @@
+import {ref, watch} from "vue";
+import {$gettext} from "@/assets/javascripts/lib/gettext";
+
+const getNestedValue = (object, path) => path.split('.').reduce((acc, key) => acc && acc[key], object);
+
+export function useSortable(data) {
+ const sortKey = ref(null);
+ const sortOrder = ref('asc');
+ const sortedData = ref([...data.value]);
+
+ const sortData = () => {
+ if (!sortKey.value) {
+ sortedData.value = [...data.value];
+ return;
+ }
+
+ sortedData.value = [...data.value].sort((a, b) => {
+ const aValue = getNestedValue(a, sortKey.value);
+ const bValue = getNestedValue(b, sortKey.value);
+ if (aValue < bValue) return sortOrder.value === 'asc' ? -1 : 1;
+ if (aValue > bValue) return sortOrder.value === 'asc' ? 1 : -1;
+ return 0;
+ });
+ };
+
+ watch([data, sortKey, sortOrder], sortData, { immediate: true });
+
+ function sortBy(key){
+ if (sortKey.value === key) {
+ sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
+ } else {
+ sortKey.value = key;
+ sortOrder.value = 'asc';
+ }
+ }
+
+ function getSortClass(key) {
+ if (sortKey.value === key) {
+ return sortOrder.value === 'asc' ? ['sortasc'] : ['sortdesc'];
+ }
+
+ return [];
+ }
+
+ function getAriaSortString(key) {
+ return key === sortKey.value
+ ? (sortOrder.value === 'asc' ? 'ascending' : 'descending')
+ : null;
+ }
+
+ function getAriaSortLabel(key, label) {
+ if (sortKey.value !== key) {
+ return null;
+ }
+
+ if (sortOrder.value === 'asc') {
+ return $gettext('Es wird aufsteigend nach der Spalte %{ label } sortiert.', { label });
+ }
+
+ return $gettext('Es wird absteigend nach der Spalte %{ label } sortiert.', { label });
+ }
+
+ return {
+ sortKey,
+ sortedData,
+ sortOrder,
+ sortBy,
+ getSortClass,
+ getAriaSortString,
+ getAriaSortLabel
+ };
+}
diff --git a/resources/vue/store/pinia/forum/ForumConfig.js b/resources/vue/store/pinia/forum/ForumConfig.js
new file mode 100644
index 0000000..d2de6b6
--- /dev/null
+++ b/resources/vue/store/pinia/forum/ForumConfig.js
@@ -0,0 +1,34 @@
+import {defineStore} from "pinia";
+import {ref} from "vue";
+
+export const useForumConfig = defineStore(
+ 'forum_config',
+ () => {
+ const isAdmin = ref(false);
+ const isModerator = ref(false);
+ const anonymousPost = ref(false);
+ const tileLayout = ref(true);
+
+ function toggleForumLayout() {
+ tileLayout.value = !tileLayout.value;
+
+ const configId = `${STUDIP.USER_ID}_FORUM_TILE_LAYOUT`;
+
+ const data = {
+ id: configId,
+ type: 'config-values',
+ attributes: { value: tileLayout.value }
+ };
+
+ STUDIP.jsonapi.PATCH(`config-values/${configId}`, { data: { data } });
+ }
+
+ return {
+ isAdmin,
+ isModerator,
+ anonymousPost,
+ tileLayout,
+ toggleForumLayout
+ }
+ }
+)
diff --git a/resources/vue/store/pinia/forum/ForumPost.js b/resources/vue/store/pinia/forum/ForumPost.js
new file mode 100644
index 0000000..9534559
--- /dev/null
+++ b/resources/vue/store/pinia/forum/ForumPost.js
@@ -0,0 +1,53 @@
+import {defineStore} from "pinia";
+import {ref} from "vue";
+export const useForumPost = defineStore(
+ 'forum_discussion_post',
+ () => {
+
+ const posts = ref([]);
+
+ function initPosts(newPosts) {
+ posts.value = newPosts;
+ }
+
+ function addPost(post) {
+ posts.value.push(post);
+ }
+
+ function updatePost(post) {
+ const postIndex = posts.value.findIndex(({ id }) => id === post.id);
+
+ posts.value[postIndex] = post;
+ }
+
+ function removePost(postId) {
+ posts.value = posts.value.filter(({ id }) => id !== postId);
+ }
+
+ function addPostReaction(reaction, postId) {
+ const postIndex = posts.value.findIndex(({ id }) => id === postId);
+
+ posts.value[postIndex].reactions.push(reaction);
+ }
+
+ function removePostReaction(reactionId, postId) {
+ const postIndex = posts.value.findIndex(({ id }) => id === postId);
+
+ const postReactions = posts.value[postIndex].reactions;
+
+ if (postReactions) {
+ posts.value[postIndex].reactions = postReactions.filter(({ id }) => id !== reactionId);
+ }
+ }
+
+ return {
+ posts,
+ initPosts,
+ addPost,
+ updatePost,
+ removePost,
+ addPostReaction,
+ removePostReaction
+ }
+ }
+)
diff --git a/templates/forms/color_input.php b/templates/forms/color_input.php
new file mode 100644
index 0000000..4cbb7f8
--- /dev/null
+++ b/templates/forms/color_input.php
@@ -0,0 +1,17 @@
+<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>">
+ <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+ <span class="textlabel">
+ <?= htmlReady($this->title) ?>
+ </span>
+ <? if ($this->required) : ?>
+ <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+ <? endif ?>
+ <input type="color"
+ v-model="<?= htmlReady($this->name) ?>"
+ name="<?= htmlReady($this->name) ?>"
+ value="<?= htmlReady($this->value) ?>"
+ id="<?= $id ?>" <?= ($this->required ? 'required aria-required="true"' : '') ?>
+ <?= $attributes ?>>
+ </label>
+
+</div>
diff --git a/templates/personal_notifications/notification.php b/templates/personal_notifications/notification.php
index 2f86a50..ad93758 100644
--- a/templates/personal_notifications/notification.php
+++ b/templates/personal_notifications/notification.php
@@ -2,8 +2,15 @@
<div class="main">
<a class="content" href="<?= URLHelper::getLink('dispatch.php/jsupdater/mark_notification_read/' . $notification['personal_notification_id']) ?>"<?= $notification['dialog'] ? ' data-dialog' : '' ?>>
<? if ($notification['avatar']): ?>
- <div class="avatar" style="background-image: url(<?= $notification['avatar'] ?>);"></div>
+ <? if (filter_var($notification['avatar'], FILTER_VALIDATE_URL)): ?>
+ <div class="avatar" style="background-image: url(<?= $notification['avatar'] ?>);"></div>
+ <? else: ?>
+ <div class="html-emoji">
+ <?= $notification['avatar'] ?>
+ </div>
+ <? endif ?>
<? endif ?>
+
<?= htmlReady($notification['text']) ?>
</a>
<button class="options mark_as_read">
diff --git a/tests/jsonapi/ForumCategoriesCreateTest.php b/tests/jsonapi/ForumCategoriesCreateTest.php
deleted file mode 100644
index 4fd96d4..0000000
--- a/tests/jsonapi/ForumCategoriesCreateTest.php
+++ /dev/null
@@ -1,67 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Routes\Forum\ForumCategoriesCreate;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumCategoriesCreateTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
- public function testShouldCreateCategory()
- {
- $credentials = $this->tester->getCredentialsForTestAutor();
- $cat = $this->createCategory($credentials);
- $course_id = 'a07535cf2f8a72df33c12ddfa4b53dde';
- $cat_document = $this->buildValidResourceCategory();
- $app = $this->tester->createApp($credentials, 'POST', '/courses/{id}/forum-categories', ForumCategoriesCreate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/courses/'.$course_id.'/forum-categories')
- ->create()
- ->setJsonApiBody($cat_document);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertTrue($response->isSuccessfulDocument([201]));
- $document = $response->document();
- $resourceObject = $document->primaryResource();
- $this->tester->assertSame($cat->entry_name, $resourceObject->attribute('title'));
- }
-
- public function testShouldNotCreateCategory()
- {
- $credentials = $this->tester->getCredentialsForTestAutor();
- $cat = $this->createCategory($credentials);
- $course_id = 'badCourse';
- $cat_document = $this->buildValidResourceCategory();
- $app = $this->tester->createApp($credentials, 'POST', '/courses/{id}/forum-categories', ForumCategoriesCreate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/courses/'.$course_id.'/forum-categories')
- ->create()
- ->setJsonApiBody($cat_document);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumCategoriesIndexTest.php b/tests/jsonapi/ForumCategoriesIndexTest.php
deleted file mode 100644
index 01ba294..0000000
--- a/tests/jsonapi/ForumCategoriesIndexTest.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Routes\Forum\ForumCategoriesIndex;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumCategoriesIndexTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
- public function testShouldShowCategory()
- {
- $credentials = $this->tester->getCredentialsForTestAutor();
- $course_id = 'a07535cf2f8a72df33c12ddfa4b53dde';
- $cat = $this->createCategory($credentials);
- $app = $this->tester->createApp($credentials, 'get', '/course/{id}/forum-categories', ForumCategoriesIndex::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/course/'.$course_id.'/forum-categories')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertTrue($response->isSuccessfulDocument([200]));
- }
-
- public function testShouldNotShowCategory()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $course_id = 'a07535cf2f8a72df33c12ddfa4b53dde';
- $cat = $this->createCategory($credentials);
-
- $app = $this->tester->createApp($credentials, 'get', '/course/{id}/forum-categories', ForumCategoriesIndex::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/course/badID/forum-categories')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumCategoriesShowTest.php b/tests/jsonapi/ForumCategoriesShowTest.php
deleted file mode 100644
index 4e8df26..0000000
--- a/tests/jsonapi/ForumCategoriesShowTest.php
+++ /dev/null
@@ -1,62 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Routes\Forum\ForumCategoriesShow;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumCategoriesShowTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
- public function testShouldShowCategories()
- {
- $credentials = $this->tester->getCredentialsForTestAutor();
- $cat = $this->createCategory($credentials);
- $app = $this->tester->createApp($credentials, 'get', '/forum-categories/{id}', ForumCategoriesShow::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/'.$cat->category_id)
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertTrue($response->isSuccessfulDocument([200]));
- $document = $response->document();
- $resourceObject = $document->primaryResource();
-
- $this->tester->assertSame($cat->entry_name, $resourceObject->attribute('title'));
- $this->tester->assertSame((int) $cat->pos, $resourceObject->attribute('position'));
- }
-
- public function testShouldNotShowCategories()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
-
- $app = $this->tester->createApp($credentials, 'get', '/forum-categories/{id}', ForumCategoriesShow::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/'.'badId')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumCategoriesUpdateTest.php b/tests/jsonapi/ForumCategoriesUpdateTest.php
deleted file mode 100644
index f2015cb..0000000
--- a/tests/jsonapi/ForumCategoriesUpdateTest.php
+++ /dev/null
@@ -1,64 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Routes\Forum\ForumCategoriesUpdate;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumCategoriesUpdateTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
- public function testShouldUpdateCategory()
- {
- $credentials = $this->tester->getCredentialsForTestAutor();
- $cat = $this->createCategory($credentials);
- $cat_document = $this->buildValidResourceCategoryUpdate();
- $app = $this->tester->createApp($credentials, 'PATCH', '/forum-categories/{id}', ForumCategoriesUpdate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/'.$cat->id)
- ->update()
- ->setJsonApiBody($cat_document);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertTrue($response->isSuccessfulDocument([200]));
- $document = $response->document();
- $resourceObject = $document->primaryResource();
- $this->tester->assertNotEquals($cat->entry_name, $resourceObject->attribute('title'));
- }
-
- public function testShouldNotUpdateCategory()
- {
- $credentials = $this->tester->getCredentialsForTestAutor();
- $cat = $this->createCategory($credentials);
- $cat_document = $this->buildValidResourceCategoryUpdate();
- $app = $this->tester->createApp($credentials, 'PATCH', '/forum-categories/{id}', ForumCategoriesUpdate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/badId')
- ->update()
- ->setJsonApiBody($cat_document);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumCategoryDeleteTest.php b/tests/jsonapi/ForumCategoryDeleteTest.php
deleted file mode 100644
index 8d144f2..0000000
--- a/tests/jsonapi/ForumCategoryDeleteTest.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Models\ForumCat;
-use JsonApi\Routes\Forum\ForumCategoriesDelete;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumCategoryDeleteTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
-
- public function testShouldDeleteEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $app = $this->tester->createApp($credentials, 'delete', '/forum-categories/{id}', ForumCategoriesDelete::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/'.$cat->id)
- ->delete();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertIsEmpty(ForumCat::find($cat->id));
- }
-
- public function testShouldNotDeleteEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $entry = $this->createEntry($credentials, $cat->id);
- $app = $this->tester->createApp($credentials, 'delete', '/forum-categories/{id}', ForumCategoriesDelete::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/badId')
- ->delete();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumEntriesCreateTest.php b/tests/jsonapi/ForumEntriesCreateTest.php
deleted file mode 100644
index 04d119e..0000000
--- a/tests/jsonapi/ForumEntriesCreateTest.php
+++ /dev/null
@@ -1,115 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Routes\Forum\ForumCategoryEntriesCreate;
-use JsonApi\Routes\Forum\ForumEntryEntriesCreate;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumEntriesCreateTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
-
- public function testShouldCreateEntryForCategory()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $content = 'some content to test';
- $title = 'entry-test-title';
- $entry_json = $this->buildValidResourceEntry($content, $title);
- $app = $this->tester->createApp($credentials, 'post', '/forum-categories/{id}/entries', ForumCategoryEntriesCreate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/'.$cat->id.'/entries')
- ->create()
- ->setJsonApiBody($entry_json);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertTrue($response->isSuccessfulDocument([201]));
- $document = $response->document();
- $resourceObject = $document->primaryResource();
- $this->tester->assertNotNull($resourceObject->attribute('title'));
- $this->tester->assertNotNull($resourceObject->attribute('content'));
- }
-
- public function testShouldNotCreateEntryForCategory()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $content = 'some content to test';
- $title = 'entry-test-title';
- $entry_json = $this->buildValidResourceEntry($content, $title);
- $app = $this->tester->createApp($credentials, 'post', '/forum-categories/{id}/entries', ForumCategoryEntriesCreate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/'.'badId'.'/entries')
- ->create()
- ->setJsonApiBody($entry_json);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-
- public function testShouldCreateEntryForEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $entry = $this->createEntry($credentials, $cat->id);
- $content = 'some new content to test';
- $title = 'entry-test-title new';
- $entry_json = $this->buildValidResourceEntry($content, $title);
- $app = $this->tester->createApp($credentials, 'post', '/forum-entries/{id}/entries', ForumEntryEntriesCreate::class);
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/'.$entry->id.'/entries')
- ->create()
- ->setJsonApiBody($entry_json);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertTrue($response->isSuccessfulDocument([201]));
- $document = $response->document();
- $resourceObject = $document->primaryResource();
- $this->tester->assertNotNull($resourceObject->attribute('title'));
- $this->tester->assertNotNull($resourceObject->attribute('content'));
- }
-
- public function testShouldNotCreateEntryForEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $entry = $this->createEntry($credentials, $cat->id);
- $content = 'some new content to test';
- $title = 'entry-test-title new';
- $entry_json = $this->buildValidResourceEntry($content, $title);
- $app = $this->tester->createApp($credentials, 'post', '/forum-entries/{id}/entries', ForumEntryEntriesCreate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/'.'badID'.'/entries')
- ->create()
- ->setJsonApiBody($entry_json);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumEntriesDeleteTest.php b/tests/jsonapi/ForumEntriesDeleteTest.php
deleted file mode 100644
index e2f0a7e..0000000
--- a/tests/jsonapi/ForumEntriesDeleteTest.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Models\ForumEntry;
-use JsonApi\Routes\Forum\ForumEntriesDelete;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumEntriesDeleteTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
-
- public function testShouldDeleteEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $entry = $this->createEntry($credentials, $cat->id);
- $app = $this->tester->createApp($credentials, 'delete', '/forum-entries/{id}', ForumEntriesDelete::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/'.$entry->id)
- ->delete();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertIsEmpty(ForumEntry::find($entry->id));
- }
-
- public function testShouldNotDeleteEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $entry = $this->createEntry($credentials, $cat->id);
- $app = $this->tester->createApp($credentials, 'delete', '/forum-entries/{id}', ForumEntriesDelete::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/badId')
- ->delete();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumEntriesShowTest.php b/tests/jsonapi/ForumEntriesShowTest.php
deleted file mode 100644
index e823842..0000000
--- a/tests/jsonapi/ForumEntriesShowTest.php
+++ /dev/null
@@ -1,154 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Models\ForumEntry as ForumEntryModel;
-use JsonApi\Routes\Forum\ForumEntriesShow;
-use JsonApi\Routes\Forum\ForumCategoryEntriesIndex;
-use JsonApi\Routes\Forum\ForumEntryEntriesIndex;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumEntriesShowTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // Tests
- public function testShouldShowEntry()
- {
- $credentials = $this->tester->getCredentialsForRoot();
- $course = \Course::find('a07535cf2f8a72df33c12ddfa4b53dde');
-
- $this->tester->assertSame(0, ForumEntryModel::countBySql('1'));
- \ForumEntry::checkRootEntry($course->id);
- $entries = ForumEntryModel::findBySql(
- 'seminar_id = ? ORDER BY depth DESC',
- [$course->id]
- );
- $this->tester->assertCount(2, $entries);
- $entry = current($entries);
-
- $app = $this->tester->createApp(
- $credentials,
- 'get',
- '/forum-entries/{id}',
- ForumEntriesShow::class
- );
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/'.$entry->id)
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
-
- $this->tester->assertTrue($response->isSuccessfulDocument([200]));
- }
-
- public function testShouldNotShowEntry()
- {
- $credentials = $this->tester->getCredentialsForRoot();
- $app = $this->tester->createApp($credentials, 'get', '/forum-entries/{id}', ForumEntriesShow::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/'.'badEntry')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-
- public function testShouldNotShowEntriesForCategory()
- {
- $credentials = $this->tester->getCredentialsForRoot();
- $cat = $this->createCategory($credentials);
- $this->createEntry($credentials, $cat->id);
- $this->createEntry($credentials, $cat->id);
- $this->createEntry($credentials, $cat->id);
-
- $app = $this->tester->createApp($credentials, 'get', '/forum-categories/{id}/entries', ForumCategoryEntriesIndex::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/badID/entries')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-
- public function testShouldShowEntriesForCategory()
- {
- $credentials = $this->tester->getCredentialsForRoot();
- $cat = $this->createCategory($credentials);
- $this->createEntry($credentials, $cat->id);
- $this->createEntry($credentials, $cat->id);
- $this->createEntry($credentials, $cat->id);
-
- $app = $this->tester->createApp($credentials, 'get', '/forum-categories/{id}/entries', ForumCategoryEntriesIndex::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-categories/'.$cat->id.'/entries')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $document = $response->document();
- $resourceObject = $document->primaryResources();
- $this->tester->assertNotNull($resourceObject);
- }
-
- public function testShouldShowEntriesForEntry()
- {
- $credentials = $this->tester->getCredentialsForRoot();
- $cat = $this->createCategory($credentials);
- $target_entry = $this->createEntry($credentials, $cat->id);
- $this->createEntry($credentials, $target_entry->id);
- $this->createEntry($credentials, $target_entry->id);
- $this->createEntry($credentials, $target_entry->id);
- $app = $this->tester->createApp($credentials, 'get', '/forum-entries/{id}/entries', ForumEntryEntriesIndex::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/'.$target_entry->topic_id.'/entries')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $document = $response->document();
- $resourceObject = $document->primaryResources();
- $this->tester->assertNotNull($resourceObject);
- }
-
- public function testShouldNotShowEntriesForEntry()
- {
- $credentials = $this->tester->getCredentialsForRoot();
- $cat = $this->createCategory($credentials);
- $targetEntry = $this->createEntry($credentials, $cat->id);
- $this->createEntry($credentials, $targetEntry->id);
- $this->createEntry($credentials, $targetEntry->id);
- $this->createEntry($credentials, $targetEntry->id);
- $app = $this->tester->createApp($credentials, 'get', '/forum-entries/{id}/entries', ForumEntryEntriesIndex::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/badTopic/entries')
- ->fetch();
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumEntriesUpdateTest.php b/tests/jsonapi/ForumEntriesUpdateTest.php
deleted file mode 100644
index db08917..0000000
--- a/tests/jsonapi/ForumEntriesUpdateTest.php
+++ /dev/null
@@ -1,66 +0,0 @@
-<?php
-
-require_once 'ForumTestHelper.php';
-
-use JsonApi\Routes\Forum\ForumEntriesUpdate;
-use JsonApi\Errors\RecordNotFoundException;
-
-class ForumEntriesUpdateTest extends \Codeception\Test\Unit
-{
- use ForumTestHelper;
-
- /**
- * @var \UnitTester
- */
- protected $tester;
-
- protected function _before()
- {
- \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh);
- }
-
- protected function _after()
- {
- }
-
- // tests
-
- public function testShouldUpdateEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $entry = $this->createEntry($credentials, $cat->id);
- $entry_json = $this->buildValidResourceEntryUpdate();
- $app = $this->tester->createApp($credentials, 'PATCH', '/forum-entries/{id}', ForumEntriesUpdate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/'.$entry->id)
- ->update()
- ->setJsonApiBody($entry_json);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertTrue($response->isSuccessfulDocument([200]));
- $document = $response->document();
- $resourceObject = $document->primaryResource();
- $this->tester->assertNotEquals($entry->name, $resourceObject->attribute('title'));
- }
-
- public function testShouldNotUpdateEntry()
- {
- $credentials = $this->tester->getCredentialsForTestDozent();
- $cat = $this->createCategory($credentials);
- $entry = $this->createEntry($credentials, $cat->id);
- $entry_json = $this->buildValidResourceEntryUpdate();
- $app = $this->tester->createApp($credentials, 'PATCH', '/forum-entries/{id}', ForumEntriesUpdate::class);
-
- $requestBuilder = $this->tester->createRequestBuilder($credentials);
- $requestBuilder
- ->setUri('/forum-entries/badId')
- ->update()
- ->setJsonApiBody($entry_json);
-
- $response = $this->tester->sendMockRequest($app, $requestBuilder->getRequest());
- $this->tester->assertSame(404, $response->getStatusCode());
- }
-}
diff --git a/tests/jsonapi/ForumTestHelper.php b/tests/jsonapi/ForumTestHelper.php
deleted file mode 100644
index 25e868d..0000000
--- a/tests/jsonapi/ForumTestHelper.php
+++ /dev/null
@@ -1,116 +0,0 @@
-<?php
-
-use JsonApi\Models\ForumCat;
-use JsonApi\Models\ForumEntry;
-
-trait ForumTestHelper
-{
- private function buildValidResourceEntry($content, $title)
- {
- return ['data' => [
- 'type' => 'forum-entries',
- 'attributes' => [
- 'title' => $title,
- 'content' => $content,
- ],
- ],
- ];
- }
-
- private function buildValidResourceEntryUpdate()
- {
- return ['data' => [
- 'type' => 'forum-entries',
- 'attributes' => [
- 'title' => 'updated entry',
- 'content' => 'this has been updated by testcase',
- ],
- ],
- ];
- }
-
- private function buildValidResourceCategory()
- {
- return [
- 'data' => [
- 'type' => 'forum-categories',
- 'attributes' => [
- 'title' => 'Test-Kategorie',
- ],
- ],
- ];
- }
-
- private function buildValidResourceCategoryUpdate()
- {
- return [
- 'data' => [
- 'type' => 'forum-categories',
- 'attributes' => [
- 'title' => 'Updated-Kategorie',
- ],
- ],
- ];
- }
-
- private function createCategory($credentials)
- {
- $seminar_id = 'a07535cf2f8a72df33c12ddfa4b53dde';
- $cat_name = 'Test-Kategorie';
- $cat = new ForumCat();
- $cat->seminar_id = $seminar_id;
- $cat->entry_name = $cat_name;
- $cat->store();
-
- return $cat;
- }
-
- private function createBadCategory($credentials)
- {
- $seminar_id = 'badCourse';
- $cat_name = 'Test-Kategorie';
- $cat = new ForumCat();
- $cat->seminar_id = $seminar_id;
- $cat->entry_name = $cat_name;
- $cat->store();
-
- return $cat;
- }
-
- private function createEntry($credentials, $category_id)
- {
- echo 'test:'.$category_id;
- if (!$parent = ForumCat::find($category_id)) {
- $entry_id = $category_id;
- $parent = ForumEntry::find($entry_id);
- }
-
- $topic_id = md5(uniqid(rand()));
- $data = array(
- 'topic_id' => $topic_id,
- 'seminar_id' => $parent->seminar_id,
- 'user_id' => $credentials['id'],
- 'name' => 'Test-Entry',
- 'content' => 'Try to append new entries',
- 'author' => $credentials['username'],
- 'anonymous' => 0,
- );
- $entry = new ForumEntry();
- $entry->setData($data);
-
- $entry->storeWith($parent, $entry);
-
- return $entry;
- }
-
- private function createBadEntry($credentials)
- {
- $entry_name = 'Test-Entry';
- $entry = new ForumEntry();
- $entry->seminar_id = 'badSeminar';
- $entry->entry_name = $entry_name;
- $entry->store();
-
- return $entry;
- }
-}