From 3acd8f79cb835037f584c39312bb935f17bc644c Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Fri, 12 Jul 2024 06:20:24 +0000
Subject: [PATCH] refactor wiki to vue sfc, fixes #4307

Closes #4307

Merge request studip/studip!3204
---
 app/controllers/course/wiki.php               |  52 ++--
 app/controllers/jsupdater.php                 | 135 ++++------
 app/views/course/wiki/edit.php                |  69 ++----
 lib/classes/VueApp.php                        |   7 +-
 lib/models/WikiOnlineEditingUser.php          |  28 ++-
 lib/models/WikiPage.php                       |  14 +-
 resources/assets/javascripts/bootstrap/vue.js |  93 +++----
 .../assets/javascripts/bootstrap/wiki.js      |  54 ----
 resources/assets/javascripts/entry-base.js    |   1 -
 resources/assets/javascripts/init.js          |   2 -
 resources/assets/javascripts/lib/jsupdater.js |   2 +-
 resources/assets/javascripts/lib/wiki.js      | 112 ---------
 resources/vue/components/WikiEditor.vue       | 232 ++++++++++++++++++
 .../vue/components/WikiEditorOnlineUsers.vue  |   7 +-
 14 files changed, 423 insertions(+), 385 deletions(-)
 delete mode 100644 resources/assets/javascripts/bootstrap/wiki.js
 delete mode 100644 resources/assets/javascripts/lib/wiki.js
 create mode 100644 resources/vue/components/WikiEditor.vue

diff --git a/app/controllers/course/wiki.php b/app/controllers/course/wiki.php
index 4de56a7ce2a..adf364312e1 100644
--- a/app/controllers/course/wiki.php
+++ b/app/controllers/course/wiki.php
@@ -446,16 +446,10 @@ class Course_WikiController extends AuthenticatedController
         }
         Navigation::activateItem('/course/wiki/start');
         $user = User::findCurrent();
-        WikiOnlineEditingUser::deleteBySQL(
-            "`page_id` = :page_id AND `chdate` < UNIX_TIMESTAMP() - :threshold",
-            [
-                'page_id' => $page->id,
-                'threshold' => WikiOnlineEditingUser::$threshold
-            ]
-        );
+        WikiOnlineEditingUser::purge($page);
         $pageData = [
             'page_id' => $page->id,
-            'user_id' => $user ? $user->id : null,
+            'user_id' => $user?->id,
         ];
         $online_user = WikiOnlineEditingUser::findOneBySQL(
             '`page_id` = :page_id AND `user_id` = :user_id',
@@ -466,11 +460,12 @@ class Course_WikiController extends AuthenticatedController
         }
         $editingUsers = WikiOnlineEditingUser::countBySQL(
             "`page_id` = ? AND `editing` = 1 AND `user_id` != ?",
-            [$page->id, $user ? $user->id : null]
+            [$page->id, $user?->id]
         );
-        $online_user->editing = $editingUsers === 0 ? 1 : 0;
+        $online_user->editing = $editingUsers === 0;
         $online_user->chdate = time();
         $online_user->store();
+
         $this->me_online = $online_user;
         $this->online_users = WikiOnlineEditingUser::findBySQL(
             "JOIN `auth_user_md5` USING (`user_id`)
@@ -478,11 +473,10 @@ class Course_WikiController extends AuthenticatedController
              ORDER BY Nachname, Vorname",
             [$page->id]
         );
-        $startPage = WikiPage::find($this->range->getConfiguration()->WIKI_STARTPAGE_ID);
         $this->contentbar = ContentBar::get()
             ->setTOC(CoreWiki::getTOC($page))
             ->setIcon(Icon::create('wiki'))
-            ->setInfo(_('Zuletzt gespeichert') .': '. '<studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)" :relative="true"></studip-date-time>');
+            ->setInfo(_('Zuletzt gespeichert') .': '. '<span class="wiki-last-edited-' . $this->page->id . '"></span>');
     }
 
     public function apply_editing_action(WikiPage $page)
@@ -513,12 +507,37 @@ class Course_WikiController extends AuthenticatedController
         }
         $online_user->store();
         $output = [
-            'me_online' => $online_user->toArray(),
+            'me_online' => ['editing' => (bool) $online_user->editing],
             'users' => $page->getOnlineUsers()
         ];
         $this->render_json($output);
     }
 
+    public function cancel_apply_editing_action(WikiPage $page)
+    {
+        if (!$page->isEditable() || !Request::isPost()) {
+            throw new AccessDeniedException();
+        }
+        $user = User::findCurrent();
+        $pageData = [
+            'page_id' => $page->id,
+            'user_id' => $user->id
+        ];
+        $online_user = WikiOnlineEditingUser::findOneBySQL(
+            '`page_id` = :page_id AND `user_id` = :user_id',
+            $pageData
+        );
+        if (!$online_user) {
+            $online_user = WikiOnlineEditingUser::build($pageData);
+        }
+        $online_user->editing_request = false;
+        $online_user->store();
+
+        $this->render_json([
+            'users' => $page->getOnlineUsers()
+        ]);
+    }
+
     public function leave_editing_action(WikiPage $page)
     {
         if (!$page->isEditable()) {
@@ -554,6 +573,7 @@ class Course_WikiController extends AuthenticatedController
             $this->render_json([
                 'error' => 'not_in_edit_mode'
             ]);
+            return;
         }
         $online_user_them = WikiOnlineEditingUser::findOneBySQL(
             '`page_id` = :page_id AND `user_id` = :user_id',
@@ -566,11 +586,11 @@ class Course_WikiController extends AuthenticatedController
             return;
         }
 
-        $online_user_me->editing = 0;
+        $online_user_me->editing = false;
         $online_user_me->store();
 
-        $online_user_them->editing_request = 1; //that will be set to 0 by the user themself
-        $online_user_them->editing = 1;
+        $online_user_them->editing_request = true; //that will be set to 0 by the user themself
+        $online_user_them->editing = true;
         $online_user_them->store();
 
         $this->render_json([
diff --git a/app/controllers/jsupdater.php b/app/controllers/jsupdater.php
index 9d09a833ff0..8860f8de58f 100644
--- a/app/controllers/jsupdater.php
+++ b/app/controllers/jsupdater.php
@@ -117,7 +117,6 @@ class JsupdaterController extends AuthenticatedController
             'messages' => $this->getMessagesUpdates($pageInfo['messages'] ?? null),
             'personalnotifications' => $this->getPersonalNotificationUpdates(),
             'questionnaire' => $this->getQuestionnaireUpdates($pageInfo['questionnaire'] ?? null),
-            'wiki_page_content' => $this->getWikiPageContents($pageInfo['wiki_page_content'] ?? null),
             'wiki_editor_status' => $this->getWikiEditorStatus($pageInfo['wiki_editor_status'] ?? null),
         ];
 
@@ -258,105 +257,79 @@ class JsupdaterController extends AuthenticatedController
         return $data;
     }
 
-    private function getWikiPageContents($pageInfo): array
+    private function getWikiEditorStatus($pageInfo): array
     {
         $data = [];
         if (!empty($pageInfo)) {
-            foreach ($pageInfo as $page_id) {
-                $page = WikiPage::find($page_id);
-                if ($page && $page->isReadable() && ($page->chdate >= Request::int('server_timestamp'))) {
-                    $data['contents'][$page_id] = wikiReady($page->content, true, $page->range_id, $page->id);
-                }
-            }
-        }
-        return $data;
-    }
+            $id = $pageInfo['id'];
 
-    private function getWikiEditorStatus($pageInfo): array
-    {
-        $data = [];
-        if (!empty($pageInfo['page_ids'])) {
             $user = User::findCurrent();
-            foreach ((array) $pageInfo['page_ids'] as $page_id) {
-                WikiOnlineEditingUser::deleteBySQL(
-                    "`page_id` = :page_id AND `chdate` < UNIX_TIMESTAMP() - :threshold",
-                    [
-                        'page_id' => $page_id,
-                        'threshold' => WikiOnlineEditingUser::$threshold
-                    ]
-                );
-                $page = WikiPage::find($page_id);
-                if ($page) {
-                    if ($page->isEditable()) {
-                        if (
-                            $pageInfo['focussed'] == $page_id
-                            && !empty($pageInfo['page_content'])
-                        ) {
-                            $page->content = \Studip\Markup::markAsHtml(
-                                $pageInfo['page_content']
-                            );
-                            if ($page->isDirty()) {
-                                $page['user_id'] = User::findCurrent()->id;
-                                $page->store();
-                            }
-                        }
-                        $onlineData = [
-                            'user_id' => $user->id,
-                            'page_id' => $page_id
-                        ];
-                        $online     = WikiOnlineEditingUser::findOneBySQL(
-                            "`user_id` = :user_id AND `page_id` = :page_id",
-                            $onlineData
-                        );
-                        if (!$online) {
-                            $online = WikiOnlineEditingUser::build($onlineData);
+            $page = WikiPage::find($id);
+            if ($page) {
+                WikiOnlineEditingUser::purge($page);
+
+                if ($page->isEditable()) {
+                    $onlineData = [
+                        'user_id' => $user->id,
+                        'page_id' => $page->id
+                    ];
+                    $online = WikiOnlineEditingUser::findOneBySQL(
+                        '`user_id` = :user_id AND `page_id` = :page_id',
+                        $onlineData
+                    );
+                    if (!$online) {
+                        $online = WikiOnlineEditingUser::build($onlineData);
+                    } elseif ($online->editing && isset($pageInfo['content'])) {
+                        $page->content = \Studip\Markup::markAsHtml($pageInfo['content']);
+                        if ($page->isDirty()) {
+                            $page->user_id = $user->id;
+                            $page->store();
                         }
+                    } else {
                         $editingUsers = WikiOnlineEditingUser::countBySQL(
-                            "`page_id` = ? AND `editing` = 1 AND `user_id` != ?",
+                            '`page_id` = ? AND `editing` = 1 AND `user_id` != ?',
                             [$page->id, $user->id]
                         );
                         if ($editingUsers > 0) {
-                            $online->editing = 0;
-                        } else if ($online->editing && $online->editing_request) {
+                            $online->editing = false;
+                        } elseif ($online->editing && $online->editing_request) {
                             // this is the mode that this user requested the editing mode and was granted to get it:
-                            $online->editing_request = 0;
-                        } else if ($online->editing_request) {
-                            $other_requests = WikiOnlineEditingUser::countBySql("`page_id` = ? AND `editing_request` = 1 AND `user_id` != ?", [
+                            $online->editing_request = false;
+                        } elseif ($online->editing_request) {
+                            $other_requests = WikiOnlineEditingUser::countBySql('`page_id` = ? AND `editing_request` = 1 AND `user_id` != ?', [
                                 $page->id,
                                 $user->id,
                             ]);
                             if ($other_requests === 0) {
-                                $online->editing_request = 0;
-                                $online->editing         = 1;
-                            }
-                        } else {
-                            if ($pageInfo['focussed'] == $page_id) {
-                                $online->editing = 1;
-                            } else {
-                                $other_users = WikiOnlineEditingUser::countBySql("`page_id` = ? AND `user_id` != ?", [
-                                    $page->id,
-                                    $user->id,
-                                ]);
-                                if ($other_users === 0) {
-                                    // if I'm the only user I don't need to lose the edit mode
-                                    $online->editing = 1;
-                                } else {
-                                    $online->editing = 0;
-                                }
+                                $online->editing_request = false;
+                                $online->editing = true;
                             }
+                        } elseif (!$pageInfo['online']) {
+                            $other_users = WikiOnlineEditingUser::countBySql('`page_id` = ? AND `user_id` != ?', [
+                                $page->id,
+                                $user->id,
+                            ]);
+                            // if I'm the only user I don't need to lose the edit mode
+                            $online->editing = $other_users === 0;
                         }
-                        $online->chdate = time();
-                        $online->store();
-                        $data['contents'][$page_id]         = wikiReady($page->content, true, $page->range_id, $page_id);
-                        $data['wysiwyg_contents'][$page_id] = $page->content;
-                        $data['pages'][$page_id]['editing'] = $online->editing;
                     }
-                    else {
-                        $data['pages'][$page_id]['editing'] = 0;
-                    }
-                    $data['pages'][$page_id]['chdate'] = $page->chdate;
-                    $data['users'][$page_id] = $page->getOnlineUsers();
+
+                    $online->chdate = time();
+                    $online->store();
+
+                    $data['editing'] = (bool) $online->editing;
                 }
+
+                if (
+                    $page->isReadable()
+                    && $page->chdate >= Request::int('server_timestamp')
+                ) {
+                    $data['content'] = wikiReady($page->content, true, $page->range_id, $page->id);
+                    $data['wysiwyg'] = $page->content;
+                    $data['chdate'] = date('c', $page->chdate);
+                }
+
+                $data['users'] = $page->getOnlineUsers();
             }
         }
         return $data;
diff --git a/app/views/course/wiki/edit.php b/app/views/course/wiki/edit.php
index df126a48a2f..438ed56bdeb 100644
--- a/app/views/course/wiki/edit.php
+++ b/app/views/course/wiki/edit.php
@@ -3,61 +3,20 @@
  * @var WikiPage $page
  * @var Course_WikiController $controller
  * @var WikiOnlineEditingUser $me_online
+ * @var ContentBar $contentbar
  */
 ?>
 
-<div class="wiki-editor-container"
-     data-page_id="<?= htmlReady($page->id) ?>"
-     data-editing="<?= htmlReady($me_online->editing) ?>"
-     data-content="<?= htmlReady(wikiReady($page->content, true, $page->range_id, $page->id)) ?>"
-     data-chdate="<?= htmlReady($page->chdate) ?>"
-     data-users="<?= htmlReady(json_encode($page->getOnlineUsers())) ?>">
-
-    <?= $contentbar ?>
-
-    <form action="<?= $controller->save($page) ?>" method="post" class="default" v-show="editing">
-        <?= CSRFProtection::tokenTag() ?>
-        <textarea class="wiki-editor size-l"
-                  ref="wiki_editor"
-                  data-editor="extraPlugins=WikiLink"
-                  name="content"><?= wysiwygReady($page->content) ?></textarea>
-
-        <div></div>
-        <label>
-            <input type="checkbox" v-model="autosave">
-            <?= _('Automatisches Speichern aktivieren.') ?>
-        </label>
-        <div>
-            <?= _('Zuletzt gespeichert') .': ' ?>
-            <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)" :relative="true"></studip-date-time>
-        </div>
-
-        <div data-dialog-button="">
-            <button class="button" :title="isChanged ? '<?= _('Den aktuellen Stand speichern.') ?>' : '<?= _('Der aktuelle Stand wurde bereits gespeichert.') ?>'">
-                <?= _('Speichern') ?>
-            </button>
-            <?= \Studip\LinkButton::create(_('Verlassen'), $controller->leave_editing($page))?>
-            <button v-for="user in requestingUsers"
-                    :key="user.user_id"
-                    @click.prevent="delegateEditMode(user.user_id)"
-                    class="button">
-                {{ $gettextInterpolate($gettext('Schreibmodus an %{name} übergeben'), { name: user.fullname }) }}
-            </button>
-        </div>
-    </form>
-
-    <div v-if="!editing" class="">
-        <div v-html="content"></div>
-        <div data-dialog-button="">
-            <button class="button"
-                    title="<?= _('Beantragen Sie, dass Sie den Text jetzt bearbeiten wollen.') ?>"
-                    @click.prevent="applyEditing">
-                <?= _('Bearbeiten beantragen') ?>
-            </button>
-            <?= \Studip\LinkButton::create(_('Verlassen'), $controller->leave_editing($page))?>
-        </div>
-    </div>
-
-    <wiki-editor-online-users :users="users"></wiki-editor-online-users>
-
-</div>
+<?= $contentbar ?>
+
+<?= Studip\VueApp::create('WikiEditor')
+    ->withProps([
+        'cancel-url'   => $controller->leave_editingURL($page),
+        'chdate'       => date('c', $page->chdate),
+        'page-content' => wikiReady($page->content, true, $page->range_id, $page->id),
+        'editing'      => (bool) $me_online->editing,
+        'page-id'      => (int) $page->id,
+        'save-url'     => $controller->saveURL($page),
+        'users'        => $page->getOnlineUsers(),
+    ])
+?>
diff --git a/lib/classes/VueApp.php b/lib/classes/VueApp.php
index 3ebae34de1b..76c9a52ae3d 100644
--- a/lib/classes/VueApp.php
+++ b/lib/classes/VueApp.php
@@ -40,6 +40,7 @@ final class VueApp implements Stringable
 
     private array $plugins = [];
     private array $props = [];
+    private array $slots = [];
     private array $stores = [];
     private array $storeData = [];
 
@@ -86,8 +87,9 @@ final class VueApp implements Stringable
      */
     public function withSlot(string $name, string|Template $content): VueApp
     {
-        $this->slots[$name] = $content instanceof Template ? $content->render() : $content;
-        return $this;
+        $clone = clone $this;
+        $clone->slots[$name] = $content instanceof Template ? $content->render() : $content;
+        return $clone;
     }
 
     /**
@@ -139,7 +141,6 @@ final class VueApp implements Stringable
     {
         $clone = clone $this;
         $clone->plugins[$plugin] = $filename ?? $plugin;
-
         return $clone;
     }
 
diff --git a/lib/models/WikiOnlineEditingUser.php b/lib/models/WikiOnlineEditingUser.php
index 5cc0df14c6b..f28ae0ed044 100644
--- a/lib/models/WikiOnlineEditingUser.php
+++ b/lib/models/WikiOnlineEditingUser.php
@@ -1,5 +1,4 @@
 <?php
-
 /**
  * WikiOnlineEditingUser.php
  *
@@ -13,14 +12,20 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  *
- * @property string page_id       database column
- * @property string user_id       database column
- * @property string id            alias column for user_id
- * @property string last_lifesign computed column read/write
+ * @property int    $id               pk
+ * @property string $user_id          database column
+ * @property int    $page_id          database column
+ * @property bool   $editing          database column
+ * @property bool   $editing_request  database column
+ * @property int    $chdate           database column
+ * @property int    $mkdate           database column
+ *
+ * @property WikiPage $page
+ * @property User $user
  */
 class WikiOnlineEditingUser extends SimpleORMap
 {
-    public static $threshold = 60 * 1;
+    public static int $threshold = 1 * 60;
 
     protected static function configure($config = [])
     {
@@ -35,4 +40,15 @@ class WikiOnlineEditingUser extends SimpleORMap
         ];
         parent::configure($config);
     }
+
+    public static function purge(WikiPage $page): void
+    {
+        WikiOnlineEditingUser::deleteBySQL(
+            '`page_id` = :page_id AND `chdate` < UNIX_TIMESTAMP() - :threshold',
+            [
+                'page_id'   => $page->id,
+                'threshold' => self::$threshold
+            ]
+        );
+    }
 }
diff --git a/lib/models/WikiPage.php b/lib/models/WikiPage.php
index b5f0101338c..52ca456b525 100644
--- a/lib/models/WikiPage.php
+++ b/lib/models/WikiPage.php
@@ -11,7 +11,8 @@
  * @author    mlunzena
  * @copyright (c) Authors
  *
- * @property array $id alias for pk
+ * @property int $id alias for pk
+ * @property int $page_id database column
  * @property string $course_id database column
  * @property string|null $user_id database column
  * @property string $name database column
@@ -289,14 +290,9 @@ class WikiPage extends SimpleORMap implements PrivacyObject
      */
     public function getOnlineUsers(): array
     {
-        $users = [];
-        WikiOnlineEditingUser::deleteBySQL(
-            "`page_id` = :page_id AND `chdate` < UNIX_TIMESTAMP() - :threshold",
-            [
-                'page_id' => $this->id,
-                'threshold' => WikiOnlineEditingUser::$threshold
-            ]
-        );
+        WikiOnlineEditingUser::purge($this);
+        $this->resetRelation('onlineeditingusers');
+
         return $this->onlineeditingusers->map(function (WikiOnlineEditingUser $editing_user) {
             return [
                 'user_id' => $editing_user->user_id,
diff --git a/resources/assets/javascripts/bootstrap/vue.js b/resources/assets/javascripts/bootstrap/vue.js
index 551dacaa073..513c796cb43 100644
--- a/resources/assets/javascripts/bootstrap/vue.js
+++ b/resources/assets/javascripts/bootstrap/vue.js
@@ -38,58 +38,65 @@ STUDIP.ready(() => {
             };
         });
 
-        STUDIP.Vue.load().then(async ({createApp, store, Vue}) => {
+        STUDIP.Vue.load().then(({createApp, store, Vue}) => {
+            const promises = [Promise.resolve()];
+
             for (const [index, name] of Object.entries(config.stores)) {
-                import(`../../../vue/store/${name}.js`).then(storeConfig => {
-                    store.registerModule(index, storeConfig.default);
+                promises.push(
+                    import(`../../../vue/store/${name}.js`).then(storeConfig => {
+                        store.registerModule(index, storeConfig.default);
 
-                    const dataElement = document.getElementById(`vue-store-data-${index}`);
-                    if (dataElement) {
-                        const data = JSON.parse(dataElement.innerText);
-                        Object.keys(data).forEach(command => {
-                            store.commit(`${index}/${command}`, data[command]);
-                        });
+                        const dataElement = document.getElementById(`vue-store-data-${index}`);
+                        if (dataElement) {
+                            const data = JSON.parse(dataElement.innerText);
+                            Object.keys(data).forEach(command => {
+                                store.commit(`${index}/${command}`, data[command]);
+                            });
 
-                        dataElement.remove();
-                    }
-                });
+                            dataElement.remove();
+                        }
+                    })
+                );
             }
 
             for (const [plugin, filename] of Object.entries(config.plugins)) {
-                import(`../../../vue/plugins/${filename}.js`)
-                    .then((temp) => Vue.use(temp[plugin], { store }));
+                promises.push(
+                    import(`../../../vue/plugins/${filename}.js`)
+                    .then((temp) => Vue.use(temp[plugin], { store }))
+                );
             }
 
+            Promise.all(promises).then(() => {
+                createApp({
+                    components,
+                    store,
 
-            createApp({
-                components,
-                store,
-
-                beforeCreate() {
-                    STUDIP.Vue.emit('VueAppWillCreate', this);
-                },
-                created() {
-                    STUDIP.Vue.emit('VueAppDidCreate', this);
-                },
-                beforeMount() {
-                    STUDIP.Vue.emit('VueAppWillMount', this);
-                },
-                mounted() {
-                    STUDIP.Vue.emit('VueAppDidMount', this);
-                },
-                beforeUpdate() {
-                    STUDIP.Vue.emit('VueAppWillUpdate', this);
-                },
-                updated() {
-                    STUDIP.Vue.emit('VueAppDidUpdate', this);
-                },
-                beforeDestroy() {
-                    STUDIP.Vue.emit('VueAppWillDestroy', this);
-                },
-                destroyed() {
-                    STUDIP.Vue.emit('VueAppDidDestroy', this);
-                },
-            }).$mount(node);
+                    beforeCreate() {
+                        STUDIP.Vue.emit('VueAppWillCreate', this);
+                    },
+                    created() {
+                        STUDIP.Vue.emit('VueAppDidCreate', this);
+                    },
+                    beforeMount() {
+                        STUDIP.Vue.emit('VueAppWillMount', this);
+                    },
+                    mounted() {
+                        STUDIP.Vue.emit('VueAppDidMount', this);
+                    },
+                    beforeUpdate() {
+                        STUDIP.Vue.emit('VueAppWillUpdate', this);
+                    },
+                    updated() {
+                        STUDIP.Vue.emit('VueAppDidUpdate', this);
+                    },
+                    beforeDestroy() {
+                        STUDIP.Vue.emit('VueAppWillDestroy', this);
+                    },
+                    destroyed() {
+                        STUDIP.Vue.emit('VueAppDidDestroy', this);
+                    },
+                }).$mount(node);
+            });
         });
 
         node.dataset.vueAppCreated = 'true';
diff --git a/resources/assets/javascripts/bootstrap/wiki.js b/resources/assets/javascripts/bootstrap/wiki.js
deleted file mode 100644
index b28c12760d4..00000000000
--- a/resources/assets/javascripts/bootstrap/wiki.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * This file contains all wiki related javascript.
- *
- * For now this is the "submit and edit" functionality via ajax.
- *
- * @author    Jan-Hendrik Willms <tleilax+studip@gmail.com>
- * @copyright Stud.IP Core Group
- * @license   GPL2 or any later version
- * @since     Stud.IP 3.3
- */
-
-
-
-STUDIP.domReady(() => {
-    STUDIP.JSUpdater.register('wiki_page_content', STUDIP.Wiki.updatePageContent, function () {
-        //update the wiki page for readers:
-        return Array.from(document.getElementsByClassName('wiki_page_content')).map(node => {
-            return node.dataset.page_id;
-        });
-    });
-
-    if (document.querySelector('.wiki-editor-container') !== null) {
-        STUDIP.Wiki.initEditor();
-    }
-
-    STUDIP.JSUpdater.register('wiki_editor_status', STUDIP.Wiki.updateEditorStatus, function () {
-        let info = {
-            page_ids: [],
-            focussed: null
-        };
-        for (let page_id in STUDIP.Wiki.Editors) {
-            info.page_ids.push(page_id);
-            let editor = STUDIP.Wiki.Editors[page_id].editor;
-            if (STUDIP.Wiki.Editors[page_id].isChanged && STUDIP.Wiki.Editors[page_id].autosave) {
-                //if either the textarea or the wysiwyg has focus:
-                info.page_content = editor.getData();
-                STUDIP.Wiki.Editors[page_id].isChanged = false;
-                STUDIP.Wiki.Editors[page_id].lastSaveDate = new Date();
-            }
-            if (editor.editing.view.document.isFocused) {
-                STUDIP.Wiki.Editors[page_id].lastFocussedDate = new Date();
-            }
-            if (new Date() - STUDIP.Wiki.Editors[page_id].lastFocussedDate < 1000 * 60) { //time after inactivity
-                info.focussed = page_id;
-            } else {
-                if (STUDIP.Wiki.Editors[page_id].users.length !== 1) {
-                    //then I will likely lose my edit mode so others can obtain it
-                    STUDIP.Wiki.Editors[page_id].editing = false;
-                }
-            }
-        }
-        return info;
-    });
-});
diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js
index 7a382b8cd6e..102a558fde1 100644
--- a/resources/assets/javascripts/entry-base.js
+++ b/resources/assets/javascripts/entry-base.js
@@ -44,7 +44,6 @@ import "./bootstrap/tour.js"
 import "./bootstrap/questionnaire.js"
 import "./bootstrap/qr_code.js"
 import "./bootstrap/startpage.js"
-import "./bootstrap/wiki.js"
 import "./bootstrap/course_wizard.js"
 import "./bootstrap/big_image_handler.js"
 import "./bootstrap/opengraph.js"
diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js
index 3a8402e4aca..b9c1b5bb0ae 100644
--- a/resources/assets/javascripts/init.js
+++ b/resources/assets/javascripts/init.js
@@ -81,7 +81,6 @@ import * as Gettext from './lib/gettext';
 import UserFilter from './lib/user_filter.js';
 import wysiwyg from './lib/wysiwyg.js';
 import ScrollToTop from './lib/scroll_to_top.js';
-import Wiki from './lib/wiki.js';
 
 const configURLHelper = _.get(window, 'STUDIP.URLHelper', {});
 const URLHelper = createURLHelper(configURLHelper);
@@ -173,5 +172,4 @@ window.STUDIP = _.assign(window.STUDIP || {}, {
     dialogReady,
     ScrollToTop,
     Vue,
-    Wiki
 });
diff --git a/resources/assets/javascripts/lib/jsupdater.js b/resources/assets/javascripts/lib/jsupdater.js
index 5069af0be09..29abed86abd 100644
--- a/resources/assets/javascripts/lib/jsupdater.js
+++ b/resources/assets/javascripts/lib/jsupdater.js
@@ -84,7 +84,7 @@ function collectData() {
     for (const [index, handler] of Object.entries(registeredHandlers)) {
         if (handler.data) {
             const thisData = $.isFunction(handler.data) ? handler.data() : handler.data;
-            if (thisData !== null && !$.isEmptyObject(thisData)) {
+            if (thisData !== null && !(typeof thisData === 'object' && $.isEmptyObject(thisData))) {
                 data[index] = thisData;
             }
         }
diff --git a/resources/assets/javascripts/lib/wiki.js b/resources/assets/javascripts/lib/wiki.js
deleted file mode 100644
index 96201186592..00000000000
--- a/resources/assets/javascripts/lib/wiki.js
+++ /dev/null
@@ -1,112 +0,0 @@
-const Wiki = {
-    updatePageContent(pageContents) {
-        if (!pageContents) {
-            return;
-        }
-        for (let page_id in pageContents.contents) {
-            $('.wiki_page_content_' + page_id).html(pageContents.contents[page_id]);
-        }
-    },
-    updateEditorStatus(editorStatus) {
-        if (!editorStatus) {
-            return;
-        }
-        for (let page_id in STUDIP.Wiki.Editors) {
-            STUDIP.Wiki.Editors[page_id].users = editorStatus.users[page_id];
-            if (!STUDIP.Wiki.Editors[page_id].editing) {
-                STUDIP.Wiki.Editors[page_id].content = editorStatus.contents[page_id];
-                STUDIP.Wiki.Editors[page_id].editor.setData(editorStatus.wysiwyg_contents[page_id]);
-            }
-            if (
-                !STUDIP.Wiki.Editors[page_id].editing
-                && editorStatus.pages[page_id].editing > 0
-            ) {
-                STUDIP.Wiki.Editors[page_id].editing = true;
-                STUDIP.Wiki.Editors[page_id].focusEditor();
-            } else {
-                STUDIP.Wiki.Editors[page_id].editing = editorStatus.pages[page_id].editing > 0;
-            }
-            STUDIP.Wiki.Editors[page_id].lastSaveDate = new Date(editorStatus.pages[page_id].chdate * 1000);
-        }
-
-    },
-    Editors: {},
-    initEditor() {
-
-        let wiki_edit_container = document.querySelectorAll( '.wiki-editor-container');
-        for (let edit_container of wiki_edit_container) {
-            let page_id = edit_container.dataset.page_id;
-
-            Promise.all([
-                STUDIP.Vue.load(),
-                import('../../../vue/components/WikiEditorOnlineUsers.vue').then((config) => config.default),
-            ]).then(([{ createApp }, WikiEditorOnlineUsers]) => {
-                return createApp({
-                    el: edit_container,
-                    data() {
-                        return {
-                            page_id: page_id,
-                            editing: edit_container.dataset.editing > 0,
-                            content: edit_container.dataset.content,
-                            users: JSON.parse(edit_container.dataset.users),
-                            editor: null,
-                            isChanged: false,
-                            lastSaveDate: new Date(edit_container.dataset.chdate * 1000),
-                            lastChangeDate: 0,
-                            lastFocussedDate: 0,
-                            autosave: true
-                        };
-                    },
-                    methods: {
-                        applyEditing() {
-                            const url = STUDIP.URLHelper.getURL('dispatch.php/course/wiki/apply_editing/' + this.page_id)
-                            $.post(url).done(output => {
-                                if (output.me_online.editing > 0) {
-                                    this.editing = true;
-                                    this.focusEditor();
-                                }
-                                this.users = output.users;
-                            });
-                        },
-                        delegateEditMode(user_id) {
-                            const url = STUDIP.URLHelper.getURL('dispatch.php/course/wiki/delegate_edit_mode/' + this.page_id + '/' + user_id);
-                            $.post(url).done(() => this.editing = false);
-                        },
-                        focusEditor() {
-                            this.$nextTick(() => {
-                                this.editor.editing.view.focus();
-                            });
-                        }
-                    },
-                    mounted() {
-                        let textarea = this.$refs['wiki_editor'];
-                        let promise = STUDIP.wysiwyg.replace(textarea);
-                        promise.then((editor) => {
-                            if (this.editing) {
-                                editor.editing.view.focus();
-                            }
-                            editor.model.document.on('change:data',() => {
-                                this.isChanged = true;
-                                this.lastChangeDate = new Date();
-                            });
-                            this.editor = editor;
-                        });
-                    },
-                    computed: {
-                        requestingUsers() {
-                            return this.users
-                                .filter(u => u.editing_request)
-                                .sort((a, b) => a.fullname.localeCompare(b.fullname));
-                        }
-                    },
-                    components: { WikiEditorOnlineUsers }
-                });
-            }).then((app) => {
-                STUDIP.Wiki.Editors[page_id] = app;
-            });
-        }
-
-    }
-};
-
-export default Wiki;
diff --git a/resources/vue/components/WikiEditor.vue b/resources/vue/components/WikiEditor.vue
new file mode 100644
index 00000000000..d1641811e8e
--- /dev/null
+++ b/resources/vue/components/WikiEditor.vue
@@ -0,0 +1,232 @@
+<template>
+    <div>
+        <form :action="saveUrl" method="post" class="default" v-show="isEditing">
+            <input type="hidden" :name="csrf.name" :value="csrf.value">
+
+            <textarea class="wiki-editor size-l"
+                      ref="wiki_editor"
+                      data-editor="extraPlugins=WikiLink"
+                      name="content"
+                      v-model="content"
+            ></textarea>
+
+            <div></div>
+            <label>
+                <input type="checkbox" v-model="autosave">
+                {{ $gettext('Automatisches Speichern aktivieren.') }}
+            </label>
+            <div>
+                {{ $gettext('Zuletzt gespeichert') }}:
+                <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)"
+                                  :relative="true"
+                ></studip-date-time>
+            </div>
+
+            <div data-dialog-button="">
+                <button class="button" :title="isChanged ? $gettext('Den aktuellen Stand speichern.') : $gettext('Der aktuelle Stand wurde bereits gespeichert.')">
+                    {{ $gettext('Speichern') }}
+                </button>
+                <a :href="cancelUrl" class="button">
+                    {{ $gettext('Verlassen') }}
+                </a>
+                <button v-for="user in requestingUsers"
+                        :key="user.user_id"
+                        @click.prevent="delegateEditMode(user.user_id)"
+                        class="button"
+                >
+                    {{ $gettextInterpolate($gettext('Schreibmodus an %{name} übergeben'), { name: user.fullname }, true) }}
+                </button>
+            </div>
+        </form>
+
+        <div v-if="!isEditing">
+            <div v-html="content"></div>
+            <div data-dialog-button>
+                <button class="button"
+                        v-if="!editingWasRequested"
+                        :title="$gettext('Beantragen Sie, dass Sie den Text jetzt bearbeiten wollen.')"
+                        @click.prevent="applyEditing()"
+                >
+                    {{ $gettext('Bearbeiten beantragen') }}
+                </button>
+                <button class="cancel button"
+                        v-else
+                        :title="$gettext('Klicken Sie, um die Anfrage zum Bearbeiten abzubrechen')"
+                        @click.prevent="cancelApplyEditing()"
+                >
+                    {{ $gettext('Bearbeiten beantragt') }}
+                </button>
+                <a :href="cancelUrl" class="button">
+                    {{ $gettext('Verlassen') }}
+                </a>
+            </div>
+        </div>
+
+        <wiki-editor-online-users :users="onlineUsers"></wiki-editor-online-users>
+
+        <mounting-portal :mount-to="`.wiki-last-edited-${pageId}`">
+            <studip-date-time :timestamp="Math.floor(lastSaveDate / 1000)"
+                              :relative="true"
+            ></studip-date-time>
+        </mounting-portal>
+    </div>
+</template>
+<script>
+import WikiEditorOnlineUsers from "./WikiEditorOnlineUsers.vue";
+import StudipDateTime from "./StudipDateTime.vue";
+import JSUpdater from "@/assets/javascripts/lib/jsupdater";
+
+export default {
+    name: 'wiki-editor',
+    components: {StudipDateTime, WikiEditorOnlineUsers },
+    props: {
+        cancelUrl: {
+            type: String,
+            required: true,
+        },
+        chdate: {
+            type: String,
+            required: true,
+        },
+        editing: {
+            type: Boolean,
+            default: true
+        },
+        offlineThreshold: {
+            type: Number,
+            default: 60 * 1000
+        },
+        pageContent: {
+            type: String,
+            default: ''
+        },
+        pageId: {
+            type: Number,
+            required: true
+        },
+        saveUrl: {
+            type: String,
+            required: true
+        },
+        users: {
+            type: Array,
+            default: () => []
+        }
+    },
+    data() {
+        return {
+            autosave: true,
+            content: this.pageContent,
+            editor: null,
+            isChanged: false,
+            isEditing: this.editing,
+            lastFocussedDate: null,
+            lastSaveDate: new Date(this.chdate),
+            onlineUsers: this.users,
+        };
+    },
+    computed: {
+        csrf() {
+            return STUDIP.CSRF_TOKEN;
+        },
+        editingWasRequested() {
+            return this.onlineUsers
+                .filter(u => u.user_id === STUDIP.USER_ID)
+                .some(u => u.editing_request);
+        },
+        isOnlineAndEditing() {
+            return this.isEditing
+                && new Date() - this.lastFocussedDate < this.offlineThreshold;
+        },
+        requestingUsers() {
+            return this.onlineUsers
+                .filter(u => u.editing_request)
+                .sort((a, b) => a.fullname.localeCompare(b.fullname));
+        }
+    },
+    methods: {
+        applyEditing() {
+            const url = STUDIP.URLHelper.getURL(`dispatch.php/course/wiki/apply_editing/${this.pageId}`)
+            $.post(url).done(output => {
+                if (output.me_online.editing > 0) {
+                    this.isEditing = true;
+                    this.focusEditor();
+                }
+                this.onlineUsers = output.users;
+            });
+        },
+        cancelApplyEditing() {
+            const url = STUDIP.URLHelper.getURL(`dispatch.php/course/wiki/cancel_apply_editing/${this.pageId}`)
+            $.post(url).done(output => {
+                this.onlineUsers = output.users;
+            });
+        },
+        delegateEditMode(user_id) {
+            const url = STUDIP.URLHelper.getURL(`dispatch.php/course/wiki/delegate_edit_mode/${this.pageId}/${user_id}`);
+            $.post(url).done(() => {
+                this.isEditing = false;
+            });
+        },
+        focusEditor() {
+            this.$nextTick(() => {
+                this.editor.editing.view.focus();
+            });
+        },
+        getUpdaterData() {
+            if (this.editor.editing.view.document.isFocused) {
+                this.lastFocussedDate = new Date();
+            }
+
+            const data = {
+                id: this.pageId,
+                online: this.isOnlineAndEditing
+            };
+
+            if (this.isChanged) {
+                data.content = this.editor.getData();
+                this.isChanged = false;
+            }
+
+            return data;
+        }
+    },
+    mounted() {
+        const textarea = this.$refs['wiki_editor'];
+
+        STUDIP.wysiwyg.replace(textarea).then((editor) => {
+            editor.model.document.on('change:data', () => {
+                if (this.autosave) {
+                    this.isChanged = true;
+                }
+            });
+
+            if (this.isEditing) {
+                this.focusEditor();
+            }
+
+            this.editor = editor;
+        });
+
+        JSUpdater.register(
+            'wiki_editor_status',
+            (content) => {
+                this.onlineUsers = content.users;
+                this.isEditing = content.editing;
+
+                if ('chdate' in content) {
+                    this.lastSaveDate = new Date(content.chdate);
+                }
+
+                if ('content' in content) {
+                    this.content = content.content;
+                }
+
+                if (!this.isEditing && 'wysiwyg' in content) {
+                    this.editor.setData(content.wysiwyg);
+                }
+            },
+            () => this.getUpdaterData()
+        )
+    }
+}
+</script>
diff --git a/resources/vue/components/WikiEditorOnlineUsers.vue b/resources/vue/components/WikiEditorOnlineUsers.vue
index b4a1b6099f5..71c7c1c9929 100644
--- a/resources/vue/components/WikiEditorOnlineUsers.vue
+++ b/resources/vue/components/WikiEditorOnlineUsers.vue
@@ -4,7 +4,7 @@
              <template #content>
                 <ol class="clean">
                     <li v-for="user in users" :key="user.user_id">
-                        <img class="avatar-small" :src="user.avatar">
+                        <img class="avatar-small" :src="user.avatar" alt="">
                         {{ user.fullname }}
 
                         <span v-if="user.editing" :title="$gettext('Diese Person hat den Bearbeitungsmodus.')">
@@ -19,10 +19,13 @@
         </SidebarWidget>
     </MountingPortal>
 </template>
-
 <script>
+import SidebarWidget from "./SidebarWidget.vue";
+import StudipIcon from "./StudipIcon.vue";
+
 export default {
     name: 'WikiEditorOnlineUsers',
+    components: {StudipIcon, SidebarWidget},
     props: {
         users: Array
     },
-- 
GitLab