From 6cf0348067900a65498e68411933eef97cfbe2eb Mon Sep 17 00:00:00 2001
From: flashwave <me@flash.moe>
Date: Tue, 18 Feb 2025 02:23:27 +0000
Subject: [PATCH] Added more functionality, no posting yet though.

---
 assets/common.js/html.js              |   2 +-
 assets/misuzu.css/comments/entry.css  |  10 -
 assets/misuzu.js/comments/api.js      |  66 ++++-
 assets/misuzu.js/comments/listing.jsx | 350 ++++++++++++++++++++------
 src/Comments/CommentsPostsData.php    |  56 ++---
 src/Comments/CommentsRoutes.php       | 170 ++++++++++---
 6 files changed, 495 insertions(+), 159 deletions(-)

diff --git a/assets/common.js/html.js b/assets/common.js/html.js
index 09aef2cf..4b7d3e4a 100644
--- a/assets/common.js/html.js
+++ b/assets/common.js/html.js
@@ -72,7 +72,7 @@ const $create = function(info, attrs, child, created) {
                         let setFunc = null;
                         if(elem[key] instanceof DOMTokenList)
                             setFunc = (ak, av) => { if(av) elem[key].add(ak); };
-                        else if(elem[key] instanceof CSS2Properties)
+                        else if(elem[key] instanceof CSSStyleDeclaration)
                             setFunc = (ak, av) => { elem[key].setProperty(ak, av); }
                         else
                             setFunc = (ak, av) => { elem[key][ak] = av; };
diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css
index 1e9bf535..b75d287b 100644
--- a/assets/misuzu.css/comments/entry.css
+++ b/assets/misuzu.css/comments/entry.css
@@ -12,16 +12,6 @@
     border-top: 1px solid var(--accent-colour);
 }
 
-.comments-entry-deleted {
-    opacity: .5;
-    transition: opacity .1s;
-}
-.comments-entry-deleted:hover,
-.comments-entry-deleted:focus,
-.comments-entry-deleted:focus-within {
-    opacity: .8;
-}
-
 .comments-entry-replies {
     margin-left: 25px;
 }
diff --git a/assets/misuzu.js/comments/api.js b/assets/misuzu.js/comments/api.js
index 9ffa4d98..107b6200 100644
--- a/assets/misuzu.js/comments/api.js
+++ b/assets/misuzu.js/comments/api.js
@@ -84,10 +84,48 @@ const MszCommentsApi = (() => {
             return status;
         },
         updatePost: async (post, args) => {
-            //
+            if(typeof post !== 'string')
+                throw 'post id must be a string';
+            if(post.trim() === '')
+                throw 'post id may not be empty';
+            if(typeof args !== 'object' || args === null)
+                throw 'args must be a non-null object';
+
+            const { status, body } = await $xhr.post(
+                `/comments/posts/${post}`,
+                { csrf: true, type: 'json' },
+                args
+            );
+            if(status === 400)
+                throw 'your update is not acceptable';
+            if(status === 401)
+                throw 'you must be logged in to do that';
+            if(status === 403)
+                throw 'you are not allowed to edit that part of the post';
+            if(status === 404)
+                throw 'that post does not exist';
+            if(status === 410)
+                throw 'that post disappeared while attempting to edit it';
+            if(status !== 200)
+                throw 'something went wrong';
+
+            return body;
         },
         deletePost: 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.delete(`/comments/posts/${post}`, { csrf: true });
+            if(status === 401)
+                throw 'you must be logged in to do that';
+            if(status === 403)
+                throw 'you are not allowed to delete that post';
+            if(status === 404)
+                throw 'that post does not exist';
+            if(status !== 204)
+                throw 'something went wrong';
         },
         restorePost: async post => {
             if(typeof post !== 'string')
@@ -98,6 +136,8 @@ const MszCommentsApi = (() => {
             const { status } = await $xhr.post(`/comments/posts/${post}/restore`, { csrf: true });
             if(status === 400)
                 throw 'that post is not deleted';
+            if(status === 401)
+                throw 'you must be logged in to do that';
             if(status === 403)
                 throw 'you are not allowed to restore posts';
             if(status === 404)
@@ -105,6 +145,24 @@ const MszCommentsApi = (() => {
             if(status !== 200)
                 throw 'something went wrong';
         },
+        nukePost: 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}/nuke`, { csrf: true });
+            if(status === 400)
+                throw 'that post is not deleted';
+            if(status === 401)
+                throw 'you must be logged in to do that';
+            if(status === 403)
+                throw 'you are not allowed to nuke 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 'post id must be a string';
@@ -122,6 +180,8 @@ const MszCommentsApi = (() => {
             );
             if(status === 400)
                 throw 'your vote is not acceptable';
+            if(status === 401)
+                throw 'you must be logged in to do that';
             if(status === 403)
                 throw 'you are not allowed to like or dislike comments';
             if(status === 404)
@@ -141,6 +201,8 @@ const MszCommentsApi = (() => {
                 `/comments/posts/${post}/vote`,
                 { csrf: true, type: 'json' }
             );
+            if(status === 401)
+                throw 'you must be logged in to do that';
             if(status === 403)
                 throw 'you are not allowed to like or dislike comments';
             if(status === 404)
diff --git a/assets/misuzu.js/comments/listing.jsx b/assets/misuzu.js/comments/listing.jsx
index 300af049..5919d2f3 100644
--- a/assets/misuzu.js/comments/listing.jsx
+++ b/assets/misuzu.js/comments/listing.jsx
@@ -1,15 +1,15 @@
 #include comments/api.js
 #include comments/form.jsx
 
-const MszCommentsEntry = function(userInfo, postInfo, root) {
+const MszCommentsEntry = function(userInfo, postInfo, listing, root) {
     userInfo ??= {};
 
     const actions = <div class="comments-entry-actions" />;
 
-    const likeAction = <button class="comments-entry-action comments-entry-action-vote-like" disabled={!userInfo.can_vote}>
+    const likeAction = <button class="comments-entry-action comments-entry-action-vote-like" disabled={!userInfo.can_vote} title="Like">
         <i class="fas fa-chevron-up" />
     </button>;
-    const dislikeAction = <button class="comments-entry-action comments-entry-action-vote-dislike" disabled={!userInfo.can_vote}>
+    const dislikeAction = <button class="comments-entry-action comments-entry-action-vote-dislike" disabled={!userInfo.can_vote} title="Dislike">
         <i class="fas fa-chevron-down" />
     </button>;
     const voteActions = <div class="comments-entry-actions-group comments-entry-actions-group-votes">
@@ -42,6 +42,9 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
     updateVotes(postInfo);
 
     const castVote = async vote => {
+        if(postInfo.deleted)
+            return;
+
         voteActions.classList.add('comments-entry-actions-group-disabled');
         likeAction.disabled = dislikeAction.disabled = true;
         try {
@@ -56,29 +59,27 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
         }
     };
 
-    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); };
-    }
+    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 listing = new MszCommentsListing({ hidden: !repliesIsArray });
+    const replies = new MszCommentsListing({ hidden: !repliesIsArray });
     if(repliesIsArray)
-        listing.addPosts(userInfo, postInfo.replies);
+        replies.addPosts(userInfo, postInfo.replies);
 
     let form = null;
     const repliesElem = <div class="comments-entry-replies">
-        {listing}
+        {replies}
     </div>;
 
-    const replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0);
+    let replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0);
     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">
+    const replyToggleElem = <button class="comments-entry-action" title="Replies">
         {replyToggleOpenElem}
         {replyToggleClosedElem}
         {replyCountElem}
@@ -89,10 +90,10 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
         replyToggleOpenElem.classList.toggle('hidden', !visible);
         replyToggleClosedElem.classList.toggle('hidden', visible);
     };
-    setReplyToggleState(listing.visible);
+    setReplyToggleState(replies.visible);
 
     const setReplyCount = count => {
-        count ??= 0;
+        replyCount = count ??= 0;
         if(count > 0) {
             replyCountElem.textContent = count.toLocaleString();
             replyToggleElem.classList.remove('hidden');
@@ -104,13 +105,13 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
         }
     };
 
-    let replyLoaded = listing.loaded;
+    let replyLoaded = replies.loaded;
     replyToggleElem.onclick = async () => {
-        setReplyToggleState(listing.visible = !listing.visible);
+        setReplyToggleState(replies.visible = !replies.visible);
         if(!replyLoaded) {
             replyLoaded = true;
             try {
-                listing.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id));
+                replies.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id));
             } catch(ex) {
                 console.error(ex);
                 setReplyToggleState(false);
@@ -124,7 +125,7 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
     };
 
     if(userInfo.can_create) {
-        const replyElem = <button class="comments-entry-action">
+        const replyElem = <button class="comments-entry-action" title="Reply">
             <span><i class="fas fa-reply" /></span>
             <span>Reply</span>
         </button>;
@@ -134,7 +135,7 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
             if(form === null) {
                 replyElem.classList.add('comments-entry-action-reply-active');
                 form = new MszCommentsForm(userInfo);
-                $insertBefore(listing.element, form.element);
+                $insertBefore(replies.element, form.element);
             } else {
                 replyElem.classList.remove('comments-entry-action-reply-active');
                 repliesElem.removeChild(form.element);
@@ -146,63 +147,95 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
     // 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) {
-            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>);
-            }
+    const deleteButton = postInfo.can_delete
+        ? <button class="comments-entry-action" title="Delete"><i class="fas fa-trash" /></button>
+        : null;
+    const restoreButton = postInfo.can_delete_any
+        ? <button class="comments-entry-action" title="Restore"><i class="fas fa-trash-restore" /></button>
+        : null;
+    const nukeButton = postInfo.can_delete_any
+        ? <button class="comments-entry-action" title="Permanently delete"><i class="fas fa-radiation-alt" /></button>
+        : null;
+    const pinButton = root && userInfo.can_pin
+        ? <button class="comments-entry-action" title="Pin"><i class="fas fa-thumbtack" /></button>
+        : null;
+    const unpinButton = root && userInfo.can_pin
+        ? <button class="comments-entry-action" title="Unpin">
+            {/*<i class="fas fa-crow" />
+            <i class="fas fa-bars" />*/}
+            <img src="https://mikoto.misaka.nl/u/1I0KnhRO/crowbar.png" width="11" height="12" alt="crowbar" />
+        </button> : null;
+
+    const miscActions = <div class="comments-entry-actions-group hidden">
+        {deleteButton}
+        {restoreButton}
+        {nukeButton}
+        {pinButton}
+        {unpinButton}
+    </div>;
+    actions.appendChild(miscActions);
+
+    const setMiscVisible = (deleted=null, pinned=null) => {
+        if(deleted !== null) {
+            if(deleteButton)
+                deleteButton.classList.toggle('hidden', deleted);
+            if(restoreButton)
+                restoreButton.classList.toggle('hidden', !deleted);
+            if(nukeButton)
+                nukeButton.classList.toggle('hidden', !deleted);
+        }
+        if(pinned !== null) {
+            if(pinButton)
+                pinButton.classList.toggle('hidden', pinned);
+            if(unpinButton)
+                unpinButton.classList.toggle('hidden', !pinned);
         }
-        if(userInfo.can_pin)
-            misc.appendChild(<button class="comments-entry-action" disabled={!userInfo.can_pin}>
-                <i class="fas fa-thumbtack" />
-            </button>);
-        actions.appendChild(misc);
-    }
 
-    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;
+        miscActions.classList.toggle('hidden', miscActions.querySelectorAll('.hidden').length === miscActions.childElementCount);
+    };
+    const setMiscDisabled = state => {
+        miscActions.classList.toggle('comments-entry-actions-group-disabled', state);
+        for(const elem of miscActions.querySelectorAll('button'))
+            elem.disabled = state;
+    };
 
-    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 }}>
+    setMiscVisible(!!postInfo.deleted, !!postInfo.pinned);
+
+    const userAvatarElem = <img alt="" width="40" height="40" class="avatar" />;
+    const userNameElem = <div class="comments-entry-user" />;
+
+    const createdTimeElem = <a href={`#comment-${postInfo.id}`} class="comments-entry-time-link" />;
+    const editedElem = <div class="comments-entry-time comments-entry-time-edited">
+        <div class="comments-entry-time-icon"><i class="fas fa-pencil-alt" /></div>
+    </div>;
+    const pinnedElem = <div class="comments-entry-time comments-entry-time-pinned">
+        <div class="comments-entry-time-icon"><i class="fas fa-thumbtack" /></div>
+    </div>;
+    const deletedElem = <div class="comments-entry-time comments-entry-time-deleted">
+        <div class="comments-entry-time-icon"><i class="fas fa-trash" /></div>
+    </div>;
+
+    const bodyElem = <div class="comments-entry-body" />;
+    const setBody = body => { bodyElem.textContent = body ?? '[deleted]'; };
+    setBody(postInfo?.body);
+
+    const element = <div id={`comment-${postInfo.id}`} data-comment={postInfo.id} class={{ 'comments-entry': true, 'comments-entry-root': root }}>
         <div class="comments-entry-main">
             <div class="comments-entry-avatar">
-                <img src={postInfo.user?.avatar ?? '/images/no-avatar.png'} alt="" width="40" height="40" class="avatar" />
+                {userAvatarElem}
             </div>
             <div class="comments-entry-wrap">
                 <div class="comments-entry-meta">
                     <div class="comments-entry-user">
-                        {postInfo.user
-                            ? <a class="comments-entry-user-link" href={postInfo.user.profile} style="color: var(--user-colour);">{postInfo.user.name}</a>
-                            : <span class="comments-entry-user-dead">Deleted user</span>}
+                        {userNameElem}
                     </div>
                     <div class="comments-entry-time">
                         <div class="comments-entry-time-icon">&mdash;</div>
-                        <a href={`#comment-${postInfo.id}`} class="comments-entry-time-link">
-                            {created !== null
-                                ? <time class="comments-entry-time-text" datetime={created.toISOString()} title={created.toString()}>{MszSakuya.formatTimeAgo(created)}</time>
-                                : 'deleted'}
-                        </a>
+                        {createdTimeElem}
                     </div>
-                    {edited !== null ? <div class="comments-entry-time comments-entry-time-edited">
-                        <div class="comments-entry-time-icon"><i class="fas fa-pencil-alt" /></div>
-                        <time class="comments-entry-time-text" datetime={edited.toISOString()} title={edited.toString()}>{MszSakuya.formatTimeAgo(edited)}</time>
-                    </div> : null}
-                    {pinned !== null ? <div class="comments-entry-time comments-entry-time-pinned">
-                        <div class="comments-entry-time-icon"><i class="fas fa-thumbtack" /></div>
-                        <time class="comments-entry-time-text" datetime={pinned.toISOString()} title={pinned.toString()}>{MszSakuya.formatTimeAgo(pinned)}</time>
-                    </div> : null}
-                    {deleted !== null ? <div class="comments-entry-time comments-entry-time-deleted">
-                        <div class="comments-entry-time-icon"><i class="fas fa-trash" /></div>
-                        <time class="comments-entry-time-text" datetime={deleted.toISOString()} title={deleted.toString()}>{MszSakuya.formatTimeAgo(deleted)}</time>
-                    </div> : null}
+                    {editedElem}
+                    {pinnedElem}
+                    {deletedElem}
                 </div>
                 <div class="comments-entry-body">{postInfo?.body ?? '[deleted]'}</div>
                 {actions.childElementCount > 0 ? actions : null}
@@ -211,7 +244,168 @@ const MszCommentsEntry = function(userInfo, postInfo, root) {
         {repliesElem}
     </div>;
 
-    MszSakuya.trackElements(element.querySelectorAll('time'));
+    const setUserInfo = userInfo => {
+        $removeChildren(userNameElem);
+        if(userInfo) {
+            if(typeof userInfo.colour === 'string')
+                element.style.setProperty('--user-colour', userInfo.colour);
+            userAvatarElem.src = userInfo.avatar;
+            userNameElem.appendChild(<a class="comments-entry-user-link" href={userInfo.profile} style="color: var(--user-colour);">{userInfo.name}</a>);
+        } else {
+            element.style.removeProperty('--user-colour');
+            userAvatarElem.src = '/images/no-avatar.png';
+            userNameElem.appendChild(<span class="comments-entry-user-dead">Deleted user</span>);
+        }
+    };
+    setUserInfo(postInfo.user);
+
+    const setCreatedTime = date => {
+        if(typeof date === 'string')
+            date = new Date(date);
+
+        $removeChildren(createdTimeElem);
+        element.dataset.commentCreated = date.getTime();
+        const time = <time class="comments-entry-time-text" datetime={date.toISOString()} title={date.toString()}>{MszSakuya.formatTimeAgo(date)}</time>;
+        createdTimeElem.appendChild(time);
+        MszSakuya.trackElement(time);
+    };
+    setCreatedTime(postInfo.created);
+
+    const updateOrderValue = () => {
+        let order = parseInt(element.dataset.commentCreated ?? 0);
+
+        if(element.dataset.commentDeleted !== undefined)
+            order -= parseInt(element.dataset.commentDeleted);
+        else if(element.dataset.commentPinned !== undefined)
+            order += parseInt(element.dataset.commentPinned);
+
+        element.dataset.commentOrder = order;
+    };
+
+    const setOptionalTime = (elem, date, name, reorder=true, textIfTrue=null) => {
+        if(typeof date === 'string')
+            date = new Date(date);
+
+        while(!(elem.lastChild instanceof HTMLDivElement))
+            elem.removeChild(elem.lastChild);
+
+        if(date) {
+            if(date instanceof Date) {
+                if(name)
+                    element.dataset[name] = date.getTime();
+
+                const timeElem = <time class="comments-entry-time-text" datetime={date.toISOString()} title={date.toString()}>{MszSakuya.formatTimeAgo(date)}</time>
+                elem.appendChild(timeElem);
+                MszSakuya.trackElement(timeElem);
+            } else {
+                // this is kiiiind of a hack but commentCreated isn't updated through this function so who cares lol !
+                if(name)
+                    element.dataset[name] = element.dataset.commentCreated;
+
+                if(typeof textIfTrue === 'string')
+                    elem.appendChild(<span>{textIfTrue}</span>);
+            }
+
+            elem.classList.remove('hidden');
+        } else {
+            if(name)
+                delete element.dataset[name];
+            elem.classList.add('hidden');
+        }
+
+        if(reorder)
+            updateOrderValue();
+    };
+
+    setOptionalTime(editedElem, postInfo.edited, 'commentEdited', false);
+    setOptionalTime(pinnedElem, postInfo.pinned, 'commentPinned', false);
+    setOptionalTime(deletedElem, postInfo.deleted, 'commentDeleted', false, 'deleted');
+    updateOrderValue();
+
+    const nukeThePost = () => {
+        if(replies.count < 1 && replyCount < 1)
+            listing.element.removeChild(element);
+        else {
+            miscActions.classList.add('hidden');
+            setMiscDisabled(true);
+            setUserInfo(null);
+            setBody(null);
+        }
+    };
+
+    if(deleteButton)
+        deleteButton.onclick = async () => {
+            setMiscDisabled(true);
+            try {
+                await MszCommentsApi.deletePost(postInfo.id);
+                if(restoreButton) {
+                    setOptionalTime(deletedElem, new Date, 'commentDeleted');
+                    listing.reorder();
+                    deleteButton.classList.add('hidden');
+                    restoreButton.classList.remove('hidden');
+                    nukeButton.classList.remove('hidden');
+                } else
+                    nukeThePost();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                setMiscDisabled(false);
+            }
+        };
+    if(restoreButton)
+        restoreButton.onclick = async () => {
+            setMiscDisabled(true);
+            try {
+                await MszCommentsApi.restorePost(postInfo.id);
+                setMiscVisible(false, null);
+                setOptionalTime(deletedElem, null, 'commentDeleted');
+                listing.reorder();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                setMiscDisabled(false);
+            }
+        };
+    if(nukeButton)
+        nukeButton.onclick = async () => {
+            setMiscDisabled(true);
+            try {
+                await MszCommentsApi.nukePost(postInfo.id);
+                nukeThePost();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                setMiscDisabled(false);
+            }
+        };
+    if(pinButton)
+        pinButton.onclick = async () => {
+            setMiscDisabled(true);
+            try {
+                const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '1' });
+                setMiscVisible(null, !!result.pinned);
+                setOptionalTime(pinnedElem, result.pinned, 'commentPinned');
+                listing.reorder();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                setMiscDisabled(false);
+            }
+        };
+    if(unpinButton)
+        unpinButton.onclick = async () => {
+            setMiscDisabled(true);
+            try {
+                const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '0' });
+                setMiscVisible(null, !!result.pinned);
+                setOptionalTime(pinnedElem, result.pinned, 'commentPinned');
+                listing.reorder();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                setMiscDisabled(false);
+            }
+        };
 
     return {
         get element() {
@@ -229,32 +423,40 @@ const MszCommentsListing = function(options) {
         {loading}
     </div>;
 
-    const addPost = function(userInfo, postInfo, parentId=null) {
-        const entry = new MszCommentsEntry(userInfo ?? {}, postInfo, root);
-        entries.set(postInfo.id, entry);
-        element.appendChild(entry.element);
-    };
-
-    return {
+    const listing = {
         get element() { return element; },
+        get count() { return element.childElementCount; },
 
         get visible() { return !element.classList.contains('hidden'); },
         set visible(state) { element.classList.toggle('hidden', !state); },
 
         get loaded() { return loading === null; },
 
-        addPost: addPost,
-        addPosts: function(userInfo, posts) {
+        reorder: () => {
+            // this feels yucky but it works
+            const items = Array.from(element.children).sort((a, b) => parseInt(b.dataset.commentOrder - a.dataset.commentOrder));
+            for(const item of items)
+                element.appendChild(item);
+        },
+
+        addPost: (userInfo, postInfo, parentId=null) => {
+            const entry = new MszCommentsEntry(userInfo ?? {}, postInfo, listing, root);
+            entries.set(postInfo.id, entry);
+            element.appendChild(entry.element);
+        },
+        addPosts: (userInfo, posts) => {
             try {
                 if(!Array.isArray(posts))
                     throw 'posts must be an array';
                 userInfo ??= {};
                 for(const postInfo of posts)
-                    addPost(userInfo, postInfo);
+                    listing.addPost(userInfo, postInfo);
             } finally {
                 loading.element.remove();
                 loading = null;
             }
         },
     };
+
+    return listing;
 };
diff --git a/src/Comments/CommentsPostsData.php b/src/Comments/CommentsPostsData.php
index 2c4e60e2..dfd1edae 100644
--- a/src/Comments/CommentsPostsData.php
+++ b/src/Comments/CommentsPostsData.php
@@ -235,46 +235,38 @@ class CommentsPostsData {
         $stmt->execute();
     }
 
-    public function editPost(CommentsPostInfo|string $infoOrId, string $body): void {
+    public function updatePost(
+        CommentsPostInfo|string $infoOrId,
+        ?string $body,
+        ?bool $pinned,
+        bool $edited = false
+    ): void {
         if($infoOrId instanceof CommentsPostInfo)
             $infoOrId = $infoOrId->id;
 
-        if(empty(trim($body)))
-            throw new InvalidArgumentException('$body may not be empty.');
+        $fields = [];
+        $values = [];
 
-        $stmt = $this->cache->get(<<<SQL
-            UPDATE msz_comments_posts
-            SET comment_text = ?,
-                comment_edited = NOW()
-            WHERE comment_id = ?
-        SQL);
-        $stmt->nextParameter($body);
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
+        if($body !== null) {
+            if(trim($body) === '')
+                throw new InvalidArgumentException('$body must be null or a non-empty string.');
 
-    public function pinPost(CommentsPostInfo|string $infoOrId): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
+            $fields[] = 'comment_text = ?';
+            $values[] = $body;
+        }
 
-        $stmt = $this->cache->get(<<<SQL
-            UPDATE msz_comments_posts
-            SET comment_pinned = COALESCE(comment_pinned, NOW())
-            WHERE comment_id = ?
-        SQL);
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
+        if($pinned !== null)
+            $fields[] = $pinned ? 'comment_pinned = COALESCE(comment_pinned, NOW())' : 'comment_pinned = NULL';
 
-    public function unpinPost(CommentsPostInfo|string $infoOrId): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
+        if($edited)
+            $fields[] = 'comment_edited = NOW()';
 
-        $stmt = $this->cache->get(<<<SQL
-            UPDATE msz_comments_posts
-            SET comment_pinned = NULL
-            WHERE comment_id = ?
-        SQL);
+        if(empty($fields))
+            return;
+
+        $stmt = $this->cache->get(sprintf('UPDATE msz_comments_posts SET %s WHERE comment_id = ?', implode(', ', $fields)));
+        foreach($values as $value)
+            $stmt->nextParameter($value);
         $stmt->nextParameter($infoOrId);
         $stmt->execute();
     }
diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php
index 753390f3..5b6a805e 100644
--- a/src/Comments/CommentsRoutes.php
+++ b/src/Comments/CommentsRoutes.php
@@ -42,6 +42,28 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         return $user;
     }
 
+    private function convertPosts(
+        IPermissionResult $perms,
+        iterable $postInfos,
+        bool $loadReplies = false
+    ): array {
+        $posts = [];
+
+        foreach($postInfos as $postInfo) {
+            $post = $this->convertPost(
+                $perms,
+                $postInfo,
+                $loadReplies ? $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) : null
+            );
+            if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies']))
+                continue;
+
+            $posts[] = $post;
+        }
+
+        return $posts;
+    }
+
     private function convertPost(
         IPermissionResult $perms,
         CommentsPostInfo $postInfo,
@@ -50,10 +72,12 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         $canViewDeleted = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
         $isDeleted = $postInfo->deleted && !$canViewDeleted;
 
-        $post = ['id' => $postInfo->id];
+        $post = [
+            'id' => $postInfo->id,
+            'created' => $postInfo->createdAt->toIso8601ZuluString(),
+        ];
         if(!$isDeleted) {
             $post['body'] = $postInfo->body;
-            $post['created'] = $postInfo->createdAt->toIso8601ZuluString();
             if($postInfo->pinned)
                 $post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString();
             if($postInfo->edited)
@@ -89,7 +113,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             if($perms->check(Perm::G_COMMENTS_EDIT_ANY))
                 $post['can_edit'] = true;
             if($perms->check(Perm::G_COMMENTS_DELETE_ANY))
-                $post['can_delete'] = true;
+                $post['can_delete'] = $post['can_delete_any'] = true;
         }
 
         if($replyInfos === null) {
@@ -97,9 +121,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             if($replies > 0)
                 $post['replies'] = $replies;
         } else {
-            $replies = [];
-            foreach($replyInfos as $replyInfo)
-                $replies[] = $this->convertPost($perms, $replyInfo);
+            $replies = $this->convertPosts($perms, $replyInfos);
             if(!empty($replies))
                 $post['replies'] = $replies;
         }
@@ -111,12 +133,8 @@ class CommentsRoutes implements RouteHandler, UrlSource {
     #[HttpMiddleware('/comments')]
     public function checkCsrf(HttpResponseBuilder $response, HttpRequest $request) {
         if(in_array($request->method, ['DELETE', 'PATCH', 'POST'])) {
-            if($request->method !== 'DELETE' && !($request->content instanceof FormHttpContent))
-                return 400;
-
             if(!$this->authInfo->loggedIn)
                 return 401;
-
             if(!CSRF::validate($request->getHeaderLine('x-csrf-token')))
                 return 403;
         }
@@ -166,17 +184,14 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             $result['user'] = $user;
         }
 
-        $posts = [];
         try {
-            $postInfos = $this->commentsCtx->posts->getPosts(
+            $posts = $this->convertPosts($perms, $this->commentsCtx->posts->getPosts(
                 categoryInfo: $categoryInfo,
                 replies: false,
-            );
-            foreach($postInfos as $postInfo) {
-                $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo);
-                $posts[] = $this->convertPost($perms, $postInfo, $replyInfos);
-            }
-        } catch(RuntimeException $ex) {}
+            ), true);
+        } catch(RuntimeException $ex) {
+            $posts = [];
+        }
 
         $result['posts'] = $posts;
 
@@ -208,9 +223,11 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         }
 
         $perms = $this->getGlobalPerms();
-        $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo);
+        $post = $this->convertPost($perms, $postInfo, $this->commentsCtx->posts->getPosts(parentInfo: $postInfo));
+        if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies']))
+            return 404;
 
-        return $this->convertPost($perms, $postInfo, $replyInfos);
+        return $post;
     }
 
     #[HttpGet('/comments/posts/([0-9]+)/replies')]
@@ -221,26 +238,73 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             return 404;
         }
 
-        $perms = $this->getGlobalPerms();
-        $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo);
-
-        $replies = [];
-        foreach($replyInfos as $replyInfo)
-            $replies[] = $this->convertPost($perms, $replyInfo);
-
-        return $replies;
+        return $this->convertPosts(
+            $this->getGlobalPerms(),
+            $this->commentsCtx->posts->getPosts(parentInfo: $postInfo)
+        );
     }
 
-    #[HttpPatch('/comments/posts/([0-9]+)')]
+    // this should be HttpPatch but PHP doesn't parse into $_POST for PATCH...
+    // fix this in the v3 router for index by just ignoring PHP's parsing altogether
+    #[HttpPost('/comments/posts/([0-9]+)')]
     public function patchPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array {
-        $perms = $this->getGlobalPerms();
-        $canEditAny = $perms->check(Perm::G_COMMENTS_EDIT_ANY);
-        $canEditOwn = $perms->check(Perm::G_COMMENTS_EDIT_OWN);
-        $canPin = $perms->check(Perm::G_COMMENTS_PIN);
-        if(!$canEditAny && !$canEditOwn && !$canPin)
-            return 403;
+        if(!($request->content instanceof FormHttpContent))
+            return 400;
 
-        return 501;
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+        } catch(RuntimeException $ex) {
+            return 404;
+        }
+
+        $perms = $this->getGlobalPerms();
+
+        if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && $postInfo->deleted)
+            return 404;
+
+        $body = null;
+        $pinned = null;
+        $edited = false;
+
+        if($request->content->hasParam('pin')) {
+            if(!$perms->check(Perm::G_COMMENTS_PIN))
+                return 403;
+
+            $pinned = !empty($request->content->getParam('pin'));
+        }
+
+        if($request->content->hasParam('body')) {
+            if(!$perms->check(Perm::G_COMMENTS_EDIT_ANY) && !($perms->check(Perm::G_COMMENTS_EDIT_OWN) && $this->authInfo->userId === $postInfo->userId))
+                return 403;
+
+            $body = (string)$request->content->getParam('body');
+            $edited = $body !== $postInfo->body;
+            if(!$edited)
+                $body = null;
+        }
+
+        $this->commentsCtx->posts->updatePost(
+            $postInfo,
+            body: $body,
+            pinned: $pinned,
+            edited: $edited,
+        );
+
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($postInfo->id);
+        } catch(RuntimeException $ex) {
+            return 410;
+        }
+
+        $result = ['id' => $postInfo->id];
+        if($body !== null)
+            $result['body'] = $postInfo->body;
+        if($pinned !== null)
+            $result['pinned'] = $postInfo->pinned ? $postInfo->pinnedAt->toIso8601ZuluString() : false;
+        if($edited)
+            $result['edited'] = $postInfo->editedAt->toIso8601ZuluString();
+
+        return $result;
     }
 
     #[HttpDelete('/comments/posts/([0-9]+)')]
@@ -251,12 +315,17 @@ class CommentsRoutes implements RouteHandler, UrlSource {
             return 404;
         }
 
+        if($postInfo->deleted)
+            return 404;
+
         $perms = $this->getGlobalPerms();
-        $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
-        if(!$canDeleteAny && !$perms->check(Perm::G_COMMENTS_DELETE_OWN))
+        if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY)
+            && !($postInfo->userId === $this->authInfo->userId && $perms->check(Perm::G_COMMENTS_DELETE_OWN)))
             return 403;
 
-        return 501;
+        $this->commentsCtx->posts->deletePost($postInfo);
+
+        return 204;
     }
 
     #[HttpPost('/comments/posts/([0-9]+)/restore')]
@@ -278,8 +347,30 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         return 200;
     }
 
+    #[HttpPost('/comments/posts/([0-9]+)/nuke')]
+    public function postPostNuke(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->nukePost($postInfo);
+
+        return 200;
+    }
+
     #[HttpPost('/comments/posts/([0-9]+)/vote')]
     public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array {
+        if(!($request->content instanceof FormHttpContent))
+            return 400;
+
         $vote = (int)$request->content->getParam('vote', FILTER_SANITIZE_NUMBER_INT);
         if($vote === 0)
             return 400;
@@ -302,7 +393,6 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         $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,