From 58e85cc67bcba787e45fd2853daa751c9b51a618 Mon Sep 17 00:00:00 2001
From: flashwave <me@flash.moe>
Date: Mon, 17 Feb 2025 02:03:38 +0000
Subject: [PATCH] Things are taking shape.

---
 assets/misuzu.css/comments/entry.css   |   8 +-
 assets/misuzu.css/comments/listing.css |   3 +
 assets/misuzu.js/comments/api.js       |  68 +++++++--
 assets/misuzu.js/comments/listing.jsx  | 198 +++++++++++++++++++------
 src/Comments/CommentsRoutes.php        | 142 +++++++++++-------
 5 files changed, 306 insertions(+), 113 deletions(-)

diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css
index 79aeac92..1e9bf535 100644
--- a/assets/misuzu.css/comments/entry.css
+++ b/assets/misuzu.css/comments/entry.css
@@ -91,11 +91,15 @@
     border-radius: 3px;
     padding: 1px;
     gap: 1px;
+    transition: opacity .1s;
 }
 .comments-entry-actions-group-votes,
 .comments-entry-actions-group-replies {
     border: 1px solid var(--accent-colour);
 }
+.comments-entry-actions-group-disabled {
+    opacity: .5;
+}
 
 .comments-entry-action {
     background: transparent;
@@ -108,12 +112,14 @@
     padding: 3px 6px;
     cursor: pointer;
     transition: background-color .2s;
+    min-width: 24px;
+    min-height: 22px;
 }
 .comments-entry-action:hover,
 .comments-entry-action:focus {
     background: var(--comments-entry-action-background-hover, #fff4);
 }
-.comments-entry-action-replies-open {
+.comments-entry-action-reply-active {
     background: #fff2;
 }
 .comments-entry-action-vote-like.comments-entry-action-vote-cast {
diff --git a/assets/misuzu.css/comments/listing.css b/assets/misuzu.css/comments/listing.css
index 353ebc87..e7569242 100644
--- a/assets/misuzu.css/comments/listing.css
+++ b/assets/misuzu.css/comments/listing.css
@@ -3,3 +3,6 @@
     flex-direction: column;
     gap: 2px;
 }
+.comments-listing-root {
+    margin: 2px;
+}
diff --git a/assets/misuzu.js/comments/api.js b/assets/misuzu.js/comments/api.js
index 6b9d933d..9ffa4d98 100644
--- a/assets/misuzu.js/comments/api.js
+++ b/assets/misuzu.js/comments/api.js
@@ -6,7 +6,10 @@ const MszCommentsApi = (() => {
             if(name.trim() === '')
                 throw 'name may not be empty';
 
-            const { status, body } = await $xhr.get(`/comments/categories/${name}`, { type: 'json' });
+            const { status, body } = await $xhr.get(
+                `/comments/categories/${name}`,
+                { type: 'json' }
+            );
             if(status === 404)
                 throw 'that category does not exist';
             if(status !== 200)
@@ -22,7 +25,11 @@ const MszCommentsApi = (() => {
             if(typeof args !== 'object' || args === null)
                 throw 'args must be a non-null object';
 
-            const { status } = await $xhr.patch(`/comments/categories/${name}`, { csrf: true }, args);
+            const { status } = await $xhr.patch(
+                `/comments/categories/${name}`,
+                { csrf: true },
+                args
+            );
 
             return status;
         },
@@ -32,7 +39,10 @@ const MszCommentsApi = (() => {
             if(post.trim() === '')
                 throw 'post id may not be empty';
 
-            const { status, body } = await $xhr.get(`/comments/posts/${post}`, { type: 'json' });
+            const { status, body } = await $xhr.get(
+                `/comments/posts/${post}`,
+                { type: 'json' }
+            );
             if(status === 404)
                 throw 'that post does not exist';
             if(status !== 200)
@@ -46,7 +56,10 @@ const MszCommentsApi = (() => {
             if(post.trim() === '')
                 throw 'post id may not be empty';
 
-            const { status, body } = await $xhr.get(`/comments/posts/${post}/replies`, { type: 'json' });
+            const { status, body } = await $xhr.get(
+                `/comments/posts/${post}/replies`,
+                { type: 'json' }
+            );
             if(status === 404)
                 throw 'that post does not exist';
             if(status !== 200)
@@ -62,7 +75,11 @@ const MszCommentsApi = (() => {
             if(typeof args !== 'object' || args === null)
                 throw 'args must be a non-null object';
 
-            const { status, body } = await $xhr.post('/comments/posts', { csrf: true }, args);
+            const { status, body } = await $xhr.post(
+                '/comments/posts',
+                { csrf: true },
+                args
+            );
 
             return status;
         },
@@ -72,17 +89,37 @@ const MszCommentsApi = (() => {
         deletePost: async post => {
             //
         },
+        restorePost: async post => {
+            if(typeof post !== 'string')
+                throw 'post id must be a string';
+            if(post.trim() === '')
+                throw 'post id may not be empty';
+
+            const { status } = await $xhr.post(`/comments/posts/${post}/restore`, { csrf: true });
+            if(status === 400)
+                throw 'that post is not deleted';
+            if(status === 403)
+                throw 'you are not allowed to restore posts';
+            if(status === 404)
+                throw 'that post does not exist';
+            if(status !== 200)
+                throw 'something went wrong';
+        },
         createVote: async (post, vote) => {
             if(typeof post !== 'string')
-                throw 'name must be a string';
+                throw 'post id must be a string';
             if(post.trim() === '')
-                throw 'name may not be empty';
+                throw 'post id may not be empty';
             if(typeof vote === 'string')
                 vote = parseInt(vote);
             if(typeof vote !== 'number' || isNaN(vote))
                 throw 'vote must be a number';
 
-            const { status } = await $xhr.post(`/comments/posts/${post}/vote`, { csrf: true }, { vote });
+            const { status, body } = await $xhr.post(
+                `/comments/posts/${post}/vote`,
+                { csrf: true, type: 'json' },
+                { vote }
+            );
             if(status === 400)
                 throw 'your vote is not acceptable';
             if(status === 403)
@@ -91,20 +128,27 @@ const MszCommentsApi = (() => {
                 throw 'that post does not exist';
             if(status !== 200)
                 throw 'something went wrong';
+
+            return body;
         },
         deleteVote: async post => {
             if(typeof post !== 'string')
-                throw 'name must be a string';
+                throw 'post id must be a string';
             if(post.trim() === '')
-                throw 'name may not be empty';
+                throw 'post id may not be empty';
 
-            const { status } = await $xhr.delete(`/comments/posts/${post}/vote`, { csrf: true });
+            const { status, body } = await $xhr.delete(
+                `/comments/posts/${post}/vote`,
+                { csrf: true, type: 'json' }
+            );
             if(status === 403)
                 throw 'you are not allowed to like or dislike comments';
             if(status === 404)
                 throw 'that post does not exist';
-            if(status !== 204)
+            if(status !== 200)
                 throw 'something went wrong';
+
+            return body;
         },
     };
 })();
diff --git a/assets/misuzu.js/comments/listing.jsx b/assets/misuzu.js/comments/listing.jsx
index 5c363f30..300af049 100644
--- a/assets/misuzu.js/comments/listing.jsx
+++ b/assets/misuzu.js/comments/listing.jsx
@@ -5,59 +5,160 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
     userInfo ??= {};
 
     const actions = <div class="comments-entry-actions" />;
-    actions.appendChild(<div class="comments-entry-actions-group comments-entry-actions-group-votes">
-        <button class={{ 'comments-entry-action': true, 'comments-entry-action-vote-like': true, 'comments-entry-action-vote-cast': postInfo.vote > 0 }} disabled={!userInfo.can_vote}>
-            <i class="fas fa-chevron-up" />
-            {postInfo.positive > 0 ? <span>{postInfo.positive.toLocaleString()}</span> : null}
-        </button>
-        <button class={{ 'comments-entry-action': true, 'comments-entry-action-vote-dislike': true, 'comments-entry-action-vote-cast': postInfo.vote < 0 }} disabled={!userInfo.can_vote}>
-            <i class="fas fa-chevron-down" />
-            {postInfo.negative < 0 ? <span>{Math.abs(postInfo.negative).toLocaleString()}</span> : null}
-        </button>
-    </div>);
+
+    const likeAction = <button class="comments-entry-action comments-entry-action-vote-like" disabled={!userInfo.can_vote}>
+        <i class="fas fa-chevron-up" />
+    </button>;
+    const dislikeAction = <button class="comments-entry-action comments-entry-action-vote-dislike" disabled={!userInfo.can_vote}>
+        <i class="fas fa-chevron-down" />
+    </button>;
+    const voteActions = <div class="comments-entry-actions-group comments-entry-actions-group-votes">
+        {likeAction}
+        {dislikeAction}
+    </div>;
+    actions.appendChild(voteActions);
+
+    const updateVoteElem = (elem, count, cast) => {
+        elem.classList.toggle('comments-entry-action-vote-cast', cast);
+
+        let counter = elem.querySelector('.js-votes');
+        if(!counter) {
+            if(count === 0)
+                return;
+
+            elem.appendChild(counter = <span class="js-votes" />);
+        }
+
+        if(count === 0)
+            elem.removeChild(counter);
+        else
+            counter.textContent = count.toLocaleString();
+    };
+    const updateVotes = votes => {
+        updateVoteElem(likeAction, votes?.positive ?? 0, votes?.vote > 0);
+        updateVoteElem(dislikeAction, Math.abs(votes?.negative ?? 0), votes?.vote < 0);
+    };
+
+    updateVotes(postInfo);
+
+    const castVote = async vote => {
+        voteActions.classList.add('comments-entry-actions-group-disabled');
+        likeAction.disabled = dislikeAction.disabled = true;
+        try {
+            updateVotes(vote === 0
+                ? await MszCommentsApi.deleteVote(postInfo.id)
+                : await MszCommentsApi.createVote(postInfo.id, vote));
+        } catch(ex) {
+            console.error(ex);
+        } finally {
+            voteActions.classList.remove('comments-entry-actions-group-disabled');
+            likeAction.disabled = dislikeAction.disabled = false;
+        }
+    };
+
+    if(postInfo.deleted && userInfo.can_vote) {
+        likeAction.onclick = () => { castVote(likeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : 1); };
+        dislikeAction.onclick = () => { castVote(dislikeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : -1); };
+    }
 
     const repliesIsArray = Array.isArray(postInfo.replies);
-    const form = userInfo?.can_create ? new MszCommentsForm(userInfo) : null;
     const listing = new MszCommentsListing({ hidden: !repliesIsArray });
     if(repliesIsArray)
         listing.addPosts(userInfo, postInfo.replies);
 
+    let form = null;
+    const repliesElem = <div class="comments-entry-replies">
+        {listing}
+    </div>;
+
     const replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0);
-    if(replyCount > 0 || userInfo.can_create) {
-        const replyElem = <button class={{ 'comments-entry-action': true, 'comments-entry-action-replies-open': listing.visible }}>
-            <i class="fas fa-reply" />
-            {replyCount > 0 ? <span>{replyCount.toLocaleString()}</span> : null}
+    const replyActionsGroup = <div class="comments-entry-actions-group comments-entry-actions-group-replies" />;
+    actions.appendChild(replyActionsGroup);
+
+    const replyToggleOpenElem = <span class="hidden"><i class="fas fa-minus" /></span>;
+    const replyToggleClosedElem = <span class="hidden"><i class="fas fa-plus" /></span>;
+    const replyCountElem = <span />;
+    const replyToggleElem = <button class="comments-entry-action">
+        {replyToggleOpenElem}
+        {replyToggleClosedElem}
+        {replyCountElem}
+    </button>;
+    replyActionsGroup.appendChild(replyToggleElem);
+
+    const setReplyToggleState = visible => {
+        replyToggleOpenElem.classList.toggle('hidden', !visible);
+        replyToggleClosedElem.classList.toggle('hidden', visible);
+    };
+    setReplyToggleState(listing.visible);
+
+    const setReplyCount = count => {
+        count ??= 0;
+        if(count > 0) {
+            replyCountElem.textContent = count.toLocaleString();
+            replyToggleElem.classList.remove('hidden');
+            replyActionsGroup.classList.remove('hidden');
+        } else {
+            replyToggleElem.classList.add('hidden');
+            if(replyActionsGroup.childElementCount < 2)
+                replyActionsGroup.classList.add('hidden');
+        }
+    };
+
+    let replyLoaded = listing.loaded;
+    replyToggleElem.onclick = async () => {
+        setReplyToggleState(listing.visible = !listing.visible);
+        if(!replyLoaded) {
+            replyLoaded = true;
+            try {
+                listing.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id));
+            } catch(ex) {
+                console.error(ex);
+                setReplyToggleState(false);
+                replyLoaded = false;
+
+                // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE
+                if(typeof ex === 'string')
+                    MszShowMessageBox(ex);
+            }
+        }
+    };
+
+    if(userInfo.can_create) {
+        const replyElem = <button class="comments-entry-action">
+            <span><i class="fas fa-reply" /></span>
+            <span>Reply</span>
         </button>;
+        replyActionsGroup.appendChild(replyElem);
 
-        let loaded = listing.loaded;
-        replyElem.onclick = async () => {
-            replyElem.classList.toggle('comments-entry-action-replies-open', listing.visible = !listing.visible);
-            if(!loaded) {
-                loaded = true;
-                try {
-                    listing.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id));
-                } catch(ex) {
-                    loaded = false;
-                    console.error(ex);
-
-                    // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE
-                    if(typeof ex === 'string')
-                        MszShowMessageBox(ex);
-                }
+        replyElem.onclick = () => {
+            if(form === null) {
+                replyElem.classList.add('comments-entry-action-reply-active');
+                form = new MszCommentsForm(userInfo);
+                $insertBefore(listing.element, form.element);
+            } else {
+                replyElem.classList.remove('comments-entry-action-reply-active');
+                repliesElem.removeChild(form.element);
+                form = null;
             }
         };
-
-        actions.appendChild(<div class="comments-entry-actions-group comments-entry-actions-group-replies">
-            {replyElem}
-        </div>);
     }
 
+    // this has to be called no earlier cus if there's less than 2 elements in the group it gets hidden on 0
+    setReplyCount(replyCount);
+
     if(postInfo.can_delete || userInfo.can_pin) {
         const misc = <div class="comments-entry-actions-group" />;
-        if(postInfo.can_delete)
-            misc.appendChild(<button class="comments-entry-action">
-                <i class="fas fa-trash" />
-            </button>);
+        if(postInfo.can_delete) {
+            if(postInfo.deleted) {
+                misc.appendChild(<button class="comments-entry-action">
+                    <i class="fas fa-trash-restore" />
+                </button>);
+            } else {
+                misc.appendChild(<button class="comments-entry-action">
+                    <i class="fas fa-trash" />
+                </button>);
+            }
+        }
         if(userInfo.can_pin)
             misc.appendChild(<button class="comments-entry-action" disabled={!userInfo.can_pin}>
                 <i class="fas fa-thumbtack" />
@@ -65,10 +166,10 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
         actions.appendChild(misc);
     }
 
-    const created = new Date(postInfo.created);
-    const edited = postInfo.edited ? new Date(postInfo.edited) : null;
-    const deleted = postInfo.deleted ? new Date(postInfo.deleted) : null;
-    const pinned = postInfo.pinned ? new Date(postInfo.pinned) : null;
+    const created = typeof postInfo.created === 'string' ? new Date(postInfo.created) : null;
+    const edited = typeof postInfo.edited === 'string' ? new Date(postInfo.edited) : null;
+    const deleted = typeof postInfo.deleted === 'string' ? new Date(postInfo.deleted) : null;
+    const pinned = typeof postInfo.pinned === 'string' ? new Date(postInfo.pinned) : null;
 
     const element = <div id={`comment-${postInfo.id}`} data-comment={postInfo.id} class={{ 'comments-entry': true, 'comments-entry-root': root, 'comments-entry-deleted': postInfo.deleted }} style={{ '--user-colour': postInfo.user?.colour }}>
         <div class="comments-entry-main">
@@ -85,7 +186,9 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
                     <div class="comments-entry-time">
                         <div class="comments-entry-time-icon">&mdash;</div>
                         <a href={`#comment-${postInfo.id}`} class="comments-entry-time-link">
-                            <time class="comments-entry-time-text" datetime={created.toISOString()} title={created.toString()}>{MszSakuya.formatTimeAgo(created)}</time>
+                            {created !== null
+                                ? <time class="comments-entry-time-text" datetime={created.toISOString()} title={created.toString()}>{MszSakuya.formatTimeAgo(created)}</time>
+                                : 'deleted'}
                         </a>
                     </div>
                     {edited !== null ? <div class="comments-entry-time comments-entry-time-edited">
@@ -101,14 +204,11 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
                         <time class="comments-entry-time-text" datetime={deleted.toISOString()} title={deleted.toString()}>{MszSakuya.formatTimeAgo(deleted)}</time>
                     </div> : null}
                 </div>
-                <div class="comments-entry-body">{postInfo.body}</div>
+                <div class="comments-entry-body">{postInfo?.body ?? '[deleted]'}</div>
                 {actions.childElementCount > 0 ? actions : null}
             </div>
         </div>
-        <div class="comments-entry-replies">
-            {form}
-            {listing}
-        </div>
+        {repliesElem}
     </div>;
 
     MszSakuya.trackElements(element.querySelectorAll('time'));
@@ -125,7 +225,7 @@ const MszCommentsListing = function(options) {
 
     let loading = new MszLoading;
     const entries = new Map;
-    const element = <div class={{ 'comments-listing': true, 'hidden': hidden }}>
+    const element = <div class={{ 'comments-listing': true, 'comments-listing-root': root, 'hidden': hidden }}>
         {loading}
     </div>;
 
diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php
index 8e0b5150..753390f3 100644
--- a/src/Comments/CommentsRoutes.php
+++ b/src/Comments/CommentsRoutes.php
@@ -47,46 +47,53 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         CommentsPostInfo $postInfo,
         ?iterable $replyInfos = null
     ): array {
-        $post = [
-            'id' => $postInfo->id,
-            'body' => $postInfo->body,
-            'created' => $postInfo->createdAt->toIso8601ZuluString(),
-        ];
-        if($postInfo->pinned)
-            $post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString();
-        if($postInfo->edited)
-            $post['edited'] = $postInfo->editedAt->toIso8601ZuluString();
+        $canViewDeleted = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
+        $isDeleted = $postInfo->deleted && !$canViewDeleted;
+
+        $post = ['id' => $postInfo->id];
+        if(!$isDeleted) {
+            $post['body'] = $postInfo->body;
+            $post['created'] = $postInfo->createdAt->toIso8601ZuluString();
+            if($postInfo->pinned)
+                $post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString();
+            if($postInfo->edited)
+                $post['edited'] = $postInfo->editedAt->toIso8601ZuluString();
+
+            if(!$isDeleted && $postInfo->userId !== null)
+                try {
+                    $post['user'] = $this->convertUser(
+                        $this->usersCtx->getUserInfo($postInfo->userId)
+                    );
+                } catch(RuntimeException $ex) {}
+
+            $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
+            $post['positive'] = $votes->positive;
+            $post['negative'] = $votes->negative;
+
+            if($this->authInfo->loggedIn) {
+                $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
+                if($voteInfo->weight !== 0)
+                    $post['vote'] = $voteInfo->weight;
+
+                $isAuthor = $this->authInfo->userId === $postInfo->userId;
+                if($isAuthor && $perms->check(Perm::G_COMMENTS_EDIT_OWN))
+                    $post['can_edit'] = true;
+                if($isAuthor && $perms->check(Perm::G_COMMENTS_DELETE_OWN))
+                    $post['can_delete'] = true;
+            }
+        }
         if($postInfo->deleted)
-            $post['deleted'] = $postInfo->deletedAt->toIso8601ZuluString();
-
-        if($postInfo->userId !== null)
-            try {
-                $post['user'] = $this->convertUser(
-                    $this->usersCtx->getUserInfo($postInfo->userId)
-                );
-            } catch(RuntimeException $ex) {}
-
-        $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
-        $post['positive'] = $votes->positive;
-        $post['negative'] = $votes->negative;
+            $post['deleted'] = $canViewDeleted ? $postInfo->deletedAt->toIso8601ZuluString() : true;
 
         if($this->authInfo->loggedIn) {
-            $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
-            if($voteInfo->weight !== 0)
-                $post['vote'] = $voteInfo->weight;
-
-            $isAuthor = $this->authInfo->userId === $postInfo->userId;
-            if($perms->check(Perm::G_COMMENTS_EDIT_ANY) || ($isAuthor && $perms->check(Perm::G_COMMENTS_EDIT_OWN)))
+            if($perms->check(Perm::G_COMMENTS_EDIT_ANY))
                 $post['can_edit'] = true;
-            if($perms->check(Perm::G_COMMENTS_DELETE_ANY) || ($isAuthor && $perms->check(Perm::G_COMMENTS_DELETE_OWN)))
+            if($perms->check(Perm::G_COMMENTS_DELETE_ANY))
                 $post['can_delete'] = true;
         }
 
         if($replyInfos === null) {
-            $replies = $this->commentsCtx->posts->countPosts(
-                parentInfo: $postInfo,
-                deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false,
-            );
+            $replies = $this->commentsCtx->posts->countPosts(parentInfo: $postInfo);
             if($replies > 0)
                 $post['replies'] = $replies;
         } else {
@@ -164,14 +171,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             $postInfos = $this->commentsCtx->posts->getPosts(
                 categoryInfo: $categoryInfo,
                 replies: false,
-                deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false,
             );
             foreach($postInfos as $postInfo) {
-                $replyInfos = $this->commentsCtx->posts->getPosts(
-                    parentInfo: $postInfo,
-                    deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false,
-                );
-
+                $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo);
                 $posts[] = $this->convertPost($perms, $postInfo, $replyInfos);
             }
         } catch(RuntimeException $ex) {}
@@ -206,10 +208,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         }
 
         $perms = $this->getGlobalPerms();
-        $replyInfos = $this->commentsCtx->posts->getPosts(
-            parentInfo: $postInfo,
-            deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false,
-        );
+        $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo);
 
         return $this->convertPost($perms, $postInfo, $replyInfos);
     }
@@ -223,10 +222,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         }
 
         $perms = $this->getGlobalPerms();
-        $replyInfos = $this->commentsCtx->posts->getPosts(
-            parentInfo: $postInfo,
-            deleted: $perms->check(Perm::G_COMMENTS_DELETE_ANY) ? null : false,
-        );
+        $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo);
 
         $replies = [];
         foreach($replyInfos as $replyInfo)
@@ -249,6 +245,12 @@ class CommentsRoutes implements RouteHandler, UrlSource {
 
     #[HttpDelete('/comments/posts/([0-9]+)')]
     public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array {
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+        } catch(RuntimeException $ex) {
+            return 404;
+        }
+
         $perms = $this->getGlobalPerms();
         $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
         if(!$canDeleteAny && !$perms->check(Perm::G_COMMENTS_DELETE_OWN))
@@ -257,8 +259,27 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         return 501;
     }
 
+    #[HttpPost('/comments/posts/([0-9]+)/restore')]
+    public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int {
+        if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
+            return 403;
+
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+        } catch(RuntimeException $ex) {
+            return 404;
+        }
+
+        if(!$postInfo->deleted)
+            return 400;
+
+        $this->commentsCtx->posts->restorePost($postInfo);
+
+        return 200;
+    }
+
     #[HttpPost('/comments/posts/([0-9]+)/vote')]
-    public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int {
+    public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array {
         $vote = (int)$request->content->getParam('vote', FILTER_SANITIZE_NUMBER_INT);
         if($vote === 0)
             return 400;
@@ -278,11 +299,19 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             max(-1, min(1, $vote))
         );
 
-        return 200;
+        $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
+        $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
+
+        $response->statusCode = 200;
+        return [
+            'vote' => $voteInfo->weight,
+            'positive' => $votes->positive,
+            'negative' => $votes->negative,
+        ];
     }
 
     #[HttpDelete('/comments/posts/([0-9]+)/vote')]
-    public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int {
+    public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array {
         if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE))
             return 403;
 
@@ -292,8 +321,19 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             return 404;
         }
 
-        $this->commentsCtx->votes->removeVote($postInfo, $this->authInfo->userInfo);
+        $this->commentsCtx->votes->removeVote(
+            $postInfo,
+            $this->authInfo->userInfo
+        );
 
-        return 204;
+        $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
+        $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
+
+        $response->statusCode = 200;
+        return [
+            'vote' => $voteInfo->weight,
+            'positive' => $votes->positive,
+            'negative' => $votes->negative,
+        ];
     }
 }