diff --git a/assets/common.css/loading.css b/assets/common.css/loading.css
new file mode 100644
index 00000000..b1adb578
--- /dev/null
+++ b/assets/common.css/loading.css
@@ -0,0 +1,36 @@
+.msz-loading {
+    display: flex;
+    justify-content: center;
+    flex-direction: column;
+    min-width: var(--msz-loading-container-width, calc(var(--msz-loading-size, 1) * 100px));
+    min-height: var(--msz-loading-container-height, calc(var(--msz-loading-size, 1) * 100px));
+}
+.msz-loading-inline {
+    display: inline-flex;
+    min-width: 0;
+    min-height: 0;
+}
+
+.msz-loading-frame {
+    display: flex;
+    justify-content: center;
+    flex: 0 0 auto;
+}
+
+.msz-loading-icon {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    grid-template-rows: repeat(3, 1fr);
+    gap: var(--msz-loading-gap, calc(var(--msz-loading-size, 1) * 1px));
+    margin: var(--msz-loading-margin, calc(var(--msz-loading-size, 1) * 10px));
+}
+
+.msz-loading-icon-block {
+    background: var(--msz-loading-colour, currentColor);
+    width: var(--msz-loading-width, calc(var(--msz-loading-size, 1) * 10px));
+    height: var(--msz-loading-height, calc(var(--msz-loading-size, 1) * 10px));
+}
+
+.msz-loading-icon-block-hidden {
+    opacity: 0;
+}
diff --git a/assets/common.css/main.css b/assets/common.css/main.css
index 1dcafd4a..d9bccaf6 100644
--- a/assets/common.css/main.css
+++ b/assets/common.css/main.css
@@ -20,3 +20,5 @@ html, body {
     --font-regular: Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
     --font-monospace: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
 }
+
+@include loading.css;
diff --git a/assets/common.js/html.js b/assets/common.js/html.js
index b1fb2caf..8b45916c 100644
--- a/assets/common.js/html.js
+++ b/assets/common.js/html.js
@@ -3,8 +3,44 @@ const $query = document.querySelector.bind(document);
 const $queryAll = document.querySelectorAll.bind(document);
 const $text = document.createTextNode.bind(document);
 
-const $insertBefore = function(ref, elem) {
-    ref.parentNode.insertBefore(elem, ref);
+const $insertBefore = function(target, element) {
+    target.parentNode.insertBefore(element, target);
+};
+
+const $appendChild = function(element, child) {
+    switch(typeof child) {
+        case 'undefined':
+            break;
+
+        case 'string':
+            element.appendChild($text(child));
+            break;
+
+        case 'function':
+            $appendChild(element, child());
+            break;
+
+        case 'object':
+            if(child === null)
+                break;
+
+            if(child instanceof Node)
+                element.appendChild(child);
+            else if(child?.element instanceof Node)
+                element.appendChild(child.element);
+            else if(typeof child?.toString === 'function')
+                element.appendChild($text(child.toString()));
+            break;
+
+        default:
+            element.appendChild($text(child.toString()));
+            break;
+    }
+};
+
+const $appendChildren = function(element, ...children) {
+    for(const child of children)
+        $appendChild(element, child);
 };
 
 const $removeChildren = function(element) {
@@ -12,120 +48,87 @@ const $removeChildren = function(element) {
         element.firstChild.remove();
 };
 
-const $jsx = (type, props, ...children) => $create({ tag: type, attrs: props, child: children });
+const $fragment = function(props, ...children) {
+    const fragment = new DocumentFragment(props);
+    $appendChildren(fragment, ...children);
+    return fragment;
+};
 
-const $create = function(info, attrs, child, created) {
-    info = info || {};
+const $element = function(type, props, ...children) {
+    if(typeof type === 'function')
+        return new type(props ?? {}, ...children);
 
-    if(typeof info === 'string') {
-        info = {tag: info};
-        if(attrs)
-            info.attrs = attrs;
-        if(child)
-            info.child = child;
-        if(created)
-            info.created = created;
-    }
+    const element = document.createElement(type ?? 'div');
 
-    const elem = document.createElement(info.tag || 'div');
-
-    if(info.attrs) {
-        const attrs = info.attrs;
-
-        for(let key in attrs) {
-            const attr = attrs[key];
-            if(attr === undefined || attr === null)
+    if(props)
+        for(let key in props) {
+            const prop = props[key];
+            if(prop === undefined || prop === null)
                 continue;
 
-            switch(typeof attr) {
+            switch(typeof prop) {
                 case 'function':
                     if(key.substring(0, 2) === 'on')
                         key = key.substring(2).toLowerCase();
-                    elem.addEventListener(key, attr);
+                    element.addEventListener(key, prop);
                     break;
 
                 case 'object':
-                    if(attr instanceof Array) {
+                    if(prop instanceof Array) {
                         if(key === 'class')
                             key = 'classList';
 
-                        const prop = elem[key];
+                        const attr = element[key];
                         let addFunc = null;
 
-                        if(prop instanceof Array)
-                            addFunc = prop.push.bind(prop);
-                        else if(prop instanceof DOMTokenList)
-                            addFunc = prop.add.bind(prop);
+                        if(attr instanceof Array)
+                            addFunc = attr.push.bind(attr);
+                        else if(attr instanceof DOMTokenList)
+                            addFunc = attr.add.bind(attr);
 
                         if(addFunc !== null) {
-                            for(let j = 0; j < attr.length; ++j)
-                                addFunc(attr[j]);
+                            for(let j = 0; j < prop.length; ++j)
+                                addFunc(prop[j]);
                         } else {
                             if(key === 'classList')
                                 key = 'class';
-                            elem.setAttribute(key, attr.toString());
+                            element.setAttribute(key, prop.toString());
                         }
                     } else {
-                        for(const attrKey in attr)
-                            elem[key][attrKey] = attr[attrKey];
+                        if(key === 'class' || key === 'className')
+                            key = 'classList';
+
+                        let setFunc = null;
+                        if(element[key] instanceof DOMTokenList)
+                            setFunc = (ak, av) => { if(av) element[key].add(ak); };
+                        else if(element[key] instanceof CSSStyleDeclaration)
+                            setFunc = (ak, av) => { element[key].setProperty(ak, av); }
+                        else
+                            setFunc = (ak, av) => { element[key][ak] = av; };
+
+                        for(const attrKey in prop) {
+                            const attrValue = prop[attrKey];
+                            if(attrValue)
+                                setFunc(attrKey, attrValue);
+                        }
                     }
                     break;
 
                 case 'boolean':
-                    if(attr)
-                        elem.setAttribute(key, '');
+                    if(prop)
+                        element.setAttribute(key, '');
                     break;
 
                 default:
                     if(key === 'className')
                         key = 'class';
-                    elem.setAttribute(key, attr.toString());
+
+                    element.setAttribute(key, prop.toString());
                     break;
             }
         }
-    }
 
-    if(info.child) {
-        let children = info.child;
+    $appendChildren(element, ...children);
 
-        if(!Array.isArray(children))
-            children = [children];
-
-        for(const child of children) {
-            switch(typeof child) {
-                case 'string':
-                    elem.appendChild(document.createTextNode(child));
-                    break;
-
-                case 'object':
-                    if(child instanceof Element) {
-                        elem.appendChild(child);
-                    } else if('element' in child) {
-                        const childElem = child.element;
-                        if(childElem instanceof Element)
-                            elem.appendChild(childElem);
-                        else
-                            elem.appendChild($create(child));
-                    } else if('getElement' in child) {
-                        const childElem = child.getElement();
-                        if(childElem instanceof Element)
-                            elem.appendChild(childElem);
-                        else
-                            elem.appendChild($create(child));
-                    } else {
-                        elem.appendChild($create(child));
-                    }
-                    break;
-
-                default:
-                    elem.appendChild(document.createTextNode(child.toString()));
-                    break;
-            }
-        }
-    }
-
-    if(info.created)
-        info.created(elem);
-
-    return elem;
+    return element;
 };
diff --git a/assets/oauth2.js/loading.jsx b/assets/common.js/loading.jsx
similarity index 51%
rename from assets/oauth2.js/loading.jsx
rename to assets/common.js/loading.jsx
index 96f0e11a..340f0e14 100644
--- a/assets/oauth2.js/loading.jsx
+++ b/assets/common.js/loading.jsx
@@ -1,7 +1,7 @@
-const MszOAuth2LoadingIcon = function() {
-    const element = <div class="oauth2-loading-icon"/>;
+const MszLoadingIcon = function() {
+    const element = <div class="msz-loading-icon"/>;
     for(let i = 0; i < 9; ++i)
-        element.appendChild(<div class="oauth2-loading-icon-block"/>);
+        element.appendChild(<div class="msz-loading-icon-block"/>);
 
     // this is moderately cursed but it'll do
     const blocks = [
@@ -26,7 +26,7 @@ const MszOAuth2LoadingIcon = function() {
             tsLastUpdate = tsCurrent;
 
             for(let i = 0; i < blocks.length; ++i)
-                blocks[(counter + i) % blocks.length].classList.toggle('oauth2-loading-icon-block-hidden', i < 3);
+                blocks[(counter + i) % blocks.length].classList.toggle('msz-loading-icon-block-hidden', i < 3);
 
             ++counter;
         } finally {
@@ -56,20 +56,51 @@ const MszOAuth2LoadingIcon = function() {
     };
 };
 
-const MszOAuth2Loading = function(element) {
+const MszLoading = function(options=null) {
+    if(typeof options !== 'object')
+        throw 'options must be an object';
+
+    let {
+        element, size, colour,
+        width, height, inline,
+        containerWidth, containerHeight,
+        gap, margin, hidden,
+    } = options ?? {};
+
     if(typeof element === 'string')
         element = document.querySelector(element);
     if(!(element instanceof HTMLElement))
-        element = <div class="oauth2-loading"/>;
+        element = <div class="msz-loading"/>;
 
-    if(!element.classList.contains('oauth2-loading'))
-        element.classList.add('oauth2-loading');
+    if(!element.classList.contains('msz-loading'))
+        element.classList.add('msz-loading');
+    if(inline)
+        element.classList.add('msz-loading-inline');
+    if(hidden)
+        element.classList.add('hidden');
+
+    if(typeof size === 'number' && size > 0)
+        element.style.setProperty('--msz-loading-size', size);
+    if(typeof containerWidth === 'string')
+        element.style.setProperty('--msz-loading-container-width', containerWidth);
+    if(typeof containerHeight === 'string')
+        element.style.setProperty('--msz-loading-container-height', containerHeight);
+    if(typeof gap === 'string')
+        element.style.setProperty('--msz-loading-gap', gap);
+    if(typeof margin === 'string')
+        element.style.setProperty('--msz-loading-margin', margin);
+    if(typeof width === 'string')
+        element.style.setProperty('--msz-loading-width', width);
+    if(typeof height === 'string')
+        element.style.setProperty('--msz-loading-height', height);
+    if(typeof colour === 'string')
+        element.style.setProperty('--msz-loading-colour', colour);
 
     let icon;
     if(element.childElementCount < 1) {
-        icon = new MszOAuth2LoadingIcon;
+        icon = new MszLoadingIcon;
         icon.play();
-        element.appendChild(<div class="oauth2-loading-frame">{icon}</div>);
+        element.appendChild(<div class="msz-loading-frame">{icon}</div>);
     }
 
     return {
diff --git a/assets/common.js/main.js b/assets/common.js/main.js
index d8d77481..dd5c0896 100644
--- a/assets/common.js/main.js
+++ b/assets/common.js/main.js
@@ -3,3 +3,5 @@
 #include html.js
 #include uniqstr.js
 #include xhr.js
+
+#include loading.jsx
diff --git a/assets/common.js/xhr.js b/assets/common.js/xhr.js
index 4940aff0..1fd5566a 100644
--- a/assets/common.js/xhr.js
+++ b/assets/common.js/xhr.js
@@ -80,7 +80,7 @@ const $xhr = (function() {
                     return headers;
                 })(xhr.getAllResponseHeaders());
 
-                if(options.csrf && headers.has('x-csrf-token'))
+                if(headers.has('x-csrf-token'))
                     $csrf.token = headers.get('x-csrf-token');
 
                 resolve({
diff --git a/assets/misuzu.css/comments/comment.css b/assets/misuzu.css/comments/comment.css
deleted file mode 100644
index 671b5d34..00000000
--- a/assets/misuzu.css/comments/comment.css
+++ /dev/null
@@ -1,159 +0,0 @@
-.comment {
-    margin: 10px;
-}
-.comment__reply-toggle {
-    display: none;
-}
-.comment__reply-toggle:checked ~ .comment--reply {
-    display: block;
-}
-
-.comment--reply {
-    display: none;
-}
-
-.comment--deleted > .comment__container {
-    opacity: .5;
-    transition: opacity .2s;
-}
-.comment--deleted > .comment__container:hover {
-    opacity: .9;
-}
-
-.comment__container {
-    display: flex;
-    margin-bottom: 3px;
-}
-
-.comment__mention {
-    color: var(--user-colour);
-    text-decoration: none;
-    font-weight: 700;
-}
-.comment__mention:hover {
-    text-decoration: underline;
-}
-
-.comment__actions {
-    list-style: none;
-    display: flex;
-    font-size: .9em;
-    align-items: center;
-}
-.comment__action {
-    color: inherit;
-    text-decoration: none;
-    vertical-align: middle;
-    cursor: pointer;
-}
-
-.comment__action:not(:last-child) {
-    margin-right: 6px;
-}
-
-.comment__action--link:hover {
-    text-decoration: underline;
-}
-
-.comment__action--post {
-    margin-left: auto;
-}
-
-.comment__action--button {
-    cursor: pointer;
-    font: 12px/20px var(--font-regular);
-    padding: 0 10px;
-}
-
-.comment__action--hide {
-    opacity: 0;
-    transition: opacity .2s;
-}
-
-.comment__action--voted {
-    font-weight: 700;
-}
-
-.comment__action__checkbox {
-    vertical-align: text-top;
-    margin-right: 2px;
-}
-
-.comment__replies .comment--indent-1,
-.comment__replies .comment--indent-2,
-.comment__replies .comment--indent-3,
-.comment__replies .comment--indent-4,
-.comment__replies .comment--indent-5 {
-    margin-left: 20px;
-}
-
-.comment__avatar {
-    flex: 0 0 auto;
-    height: 50px;
-    width: 50px;
-    margin-right: 5px;
-}
-.comment__replies .comment__avatar {
-    width: 40px;
-    height: 40px;
-}
-
-.comment__content {
-    flex: 1 1 auto;
-    display: flex;
-    flex-direction: column;
-    overflow: hidden;
-    word-wrap: break-word;
-    padding-left: 5px;
-}
-.comment__content:hover .comment__action--hide {
-    opacity: 1;
-}
-
-.comment__info {
-    display: inline-flex;
-}
-
-.comment__text {
-    margin-right: 2px;
-}
-.comment__text--input {
-    min-width: 100%;
-    max-width: 100%;
-    min-height: 50px;
-    font: 12px/20px var(--font-regular);
-    margin-right: 1px;
-}
-
-.comment__user {
-    color: var(--user-colour);
-    text-decoration: none;
-}
-.comment__user--link:hover {
-    text-decoration: underline;
-}
-
-.comment__date,
-.comment__pin {
-    color: #666;
-    font-size: .9em;
-    margin-left: 8px;
-}
-
-.comment__link {
-    color: #666;
-    display: inline-flex;
-    text-decoration: none;
-
-    &:hover {
-        text-decoration: underline;
-    }
-}
-
-.comment__pin {
-    margin-left: 4px;
-}
-.comment__pin:before {
-    content: "-";
-    padding-right: 4px;
-}
diff --git a/assets/misuzu.css/comments/comments.css b/assets/misuzu.css/comments/comments.css
deleted file mode 100644
index 8b381303..00000000
--- a/assets/misuzu.css/comments/comments.css
+++ /dev/null
@@ -1,36 +0,0 @@
-.comments {
-    --comments-max-height: 600px;
-    margin: 1px;
-    overflow: hidden;
-    word-wrap: break-word;
-}
-.comments__listing {
-    overflow-y: auto;
-}
-.comments__listing--limit {
-    max-height: var(--comments-max-height);
-}
-
-/*.comments__input,*/
-.comments__javascript,
-.comments__notice--staff {
-    border-bottom: 1px solid var(--accent-colour);
-    padding-bottom: 1px;
-    margin-bottom: 1px;
-}
-
-.comments__none,
-.comments__javascript,
-.comments__notice {
-    padding: 10px;
-    font-size: 1.2em;
-    text-align: center;
-}
-
-.comments__notice__link {
-    color: var(--accent-colour);
-    text-decoration: none;
-}
-.comments__notice__link:hover {
-    text-decoration: underline;
-}
diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css
new file mode 100644
index 00000000..dea52377
--- /dev/null
+++ b/assets/misuzu.css/comments/entry.css
@@ -0,0 +1,121 @@
+.comments-entry-main {
+    display: grid;
+    grid-template-columns: 46px 1fr;
+    gap: 2px;
+}
+
+.comments-entry-root {
+    padding-bottom: 2px;
+    border-top: 1px solid var(--accent-colour);
+}
+
+.comments-entry-replies {
+    margin-left: 25px;
+}
+
+.comments-entry-avatar {
+    flex: 0 0 auto;
+    padding: 4px;
+}
+.comments-entry-wrap {
+    flex: 0 1 auto;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+}
+
+.comments-entry-meta {
+    display: flex;
+    gap: 4px;
+    margin-top: 4px;
+}
+
+.comments-entry-user {
+    display: flex;
+}
+.comments-entry-user-link {
+    text-decoration: none;
+}
+.comments-entry-user-link:hover,
+.comments-entry-user-link:focus {
+    text-decoration: underline solid var(--user-colour, var(--text-colour, #fff));
+}
+.comments-entry-user-dead {
+    text-decoration: line-through;
+}
+
+.comments-entry-time {
+    display: flex;
+    gap: 6px;
+}
+.comments-entry-time-edited,
+.comments-entry-time-pinned,
+.comments-entry-time-deleted {
+    margin-left: 6px;
+}
+.comments-entry-time-pinned .comments-entry-time-icon {
+    rotate: 45deg;
+}
+.comments-entry-time-link {
+    color: inherit;
+    text-decoration: none;
+}
+.comments-entry-time-link:hover,
+.comments-entry-time-link:focus {
+    text-decoration: underline;
+}
+
+.comments-entry-actions {
+    display: flex;
+    gap: 2px;
+    margin-top: 2px;
+}
+.comments-entry-actions-group {
+    display: flex;
+    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;
+    border-width: 0;
+    border-radius: 2px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    padding: 3px 6px;
+    cursor: pointer;
+    transition: background-color .1s;
+    min-width: 24px;
+    min-height: 22px;
+    color: inherit;
+}
+.comments-entry-action:not([disabled]):hover,
+.comments-entry-action:not([disabled]):focus {
+    background: var(--comments-entry-action-background-hover, #fff4);
+}
+.comments-entry-action-reply-active {
+    background: #fff2;
+}
+.comments-entry-action-vote-like.comments-entry-action-vote-cast {
+    background: #0808;
+}
+.comments-entry-action-vote-like {
+    --comments-entry-action-background-hover: #0804;
+}
+.comments-entry-action-vote-dislike.comments-entry-action-vote-cast {
+    background: #c008;
+}
+.comments-entry-action-vote-dislike {
+    --comments-entry-action-background-hover: #c004;
+}
diff --git a/assets/misuzu.css/comments/form.css b/assets/misuzu.css/comments/form.css
new file mode 100644
index 00000000..101054f3
--- /dev/null
+++ b/assets/misuzu.css/comments/form.css
@@ -0,0 +1,75 @@
+.comments-form {
+    border: 1px solid var(--accent-colour);
+    border-radius: 3px;
+    margin: 2px 0;
+    display: grid;
+    grid-template-columns: 46px 1fr;
+    transition: opacity .1s;
+}
+.comments-form-root {
+    margin: 2px;
+}
+.comments-form-disabled {
+    opacity: .5;
+}
+
+.comments-form-avatar {
+    flex: 0 0 auto;
+    padding: 3px;
+}
+.comments-form-wrap {
+    display: grid;
+    grid-template-rows: 1fr 32px;
+    gap: 2px;
+    margin: 3px;
+    margin-left: 0;
+    overflow: hidden;
+}
+
+.comments-form-input {
+    overflow: hidden;
+}
+.comments-form-input textarea {
+    min-width: 100%;
+    max-width: 100%;
+    width: 100%;
+    min-height: 40px;
+    height: 0;
+}
+.comments-form-root .comments-form-input textarea {
+    min-height: 60px;
+}
+
+.comments-form-actions {
+    display: flex;
+    align-items: center;
+    overflow: hidden;
+    gap: 6px;
+}
+
+.comments-form-status {
+    flex: 1 1 auto;
+    font-size: 1.2em;
+    line-height: 1.4em;
+    padding: 0 6px;
+    overflow: hidden;
+    transition: color .2s;
+}
+.comments-form-status-text {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+.comments-form-status-error {
+    color: #c00;
+}
+
+.comments-form-pin {
+    flex: 0 0 auto;
+    font-size: 1.2em;
+    line-height: 1.4em;
+}
+
+.comments-form-post {
+    flex: 0 0 auto;
+}
diff --git a/assets/misuzu.css/comments/listing.css b/assets/misuzu.css/comments/listing.css
new file mode 100644
index 00000000..e7569242
--- /dev/null
+++ b/assets/misuzu.css/comments/listing.css
@@ -0,0 +1,8 @@
+.comments-listing {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+}
+.comments-listing-root {
+    margin: 2px;
+}
diff --git a/assets/misuzu.css/comments/main.css b/assets/misuzu.css/comments/main.css
new file mode 100644
index 00000000..42270309
--- /dev/null
+++ b/assets/misuzu.css/comments/main.css
@@ -0,0 +1,5 @@
+@include comments/form.css;
+@include comments/entry.css;
+@include comments/listing.css;
+@include comments/notice.css;
+@include comments/options.css;
diff --git a/assets/misuzu.css/comments/notice.css b/assets/misuzu.css/comments/notice.css
new file mode 100644
index 00000000..5d3cf985
--- /dev/null
+++ b/assets/misuzu.css/comments/notice.css
@@ -0,0 +1,14 @@
+.comments-notice {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-wrap: wrap;
+    font-size: 1.4em;
+    line-height: 1.5em;
+    gap: 6px;
+    padding: 12px;
+    margin: 2px;
+}
+.comments-notice-inner {
+    flex: 0 1 auto;
+}
diff --git a/assets/misuzu.css/comments/options.css b/assets/misuzu.css/comments/options.css
new file mode 100644
index 00000000..e80cebb1
--- /dev/null
+++ b/assets/misuzu.css/comments/options.css
@@ -0,0 +1,32 @@
+.comments-options {
+    display: flex;
+    justify-content: flex-end;
+    margin: 2px;
+    padding: 6px;
+    gap: 6px;
+}
+
+.comments-options-actions {
+    display: flex;
+    gap: 6px;
+}
+
+.comments-options-action {
+    color: inherit;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    padding: 4px 8px;
+    background-color: transparent;
+    border-width: 0;
+    border-radius: 4px;
+    transition: background-color .1s, opacity .1s;
+}
+.comments-options-action[disabled] {
+    opacity: .5;
+}
+.comments-options-action:not([disabled]):hover,
+.comments-options-action:not([disabled]):focus {
+    background-color: #fff4;
+}
diff --git a/assets/misuzu.css/main.css b/assets/misuzu.css/main.css
index ed1016d1..b5010f80 100644
--- a/assets/misuzu.css/main.css
+++ b/assets/misuzu.css/main.css
@@ -121,8 +121,7 @@ html {
 @include changelog/log.css;
 @include changelog/pagination.css;
 
-@include comments/comment.css;
-@include comments/comments.css;
+@include comments/main.css;
 
 @include forum/actions.css;
 @include forum/categories.css;
diff --git a/assets/misuzu.js/comments/api.js b/assets/misuzu.js/comments/api.js
new file mode 100644
index 00000000..5bb680d6
--- /dev/null
+++ b/assets/misuzu.js/comments/api.js
@@ -0,0 +1,144 @@
+const MszCommentsApi = (() => {
+    return {
+        getCategory: async name => {
+            if(typeof name !== 'string' || name.trim() === '')
+                throw new Error('name is not a valid category name');
+
+            const { status, body } = await $xhr.get(
+                `/comments/categories/${name}`,
+                { type: 'json' }
+            );
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+        updateCategory: async (name, args) => {
+            if(typeof name !== 'string' || name.trim() === '')
+                throw new Error('name is not a valid category name');
+            if(typeof args !== 'object' || args === null)
+                throw new Error('args must be a non-null object');
+
+            const { status, body } = await $xhr.post(
+                `/comments/categories/${name}`,
+                { csrf: true, type: 'json' },
+                args
+            );
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+        getPost: async post => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+
+            const { status, body } = await $xhr.get(
+                `/comments/posts/${post}`,
+                { type: 'json' }
+            );
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+        getPostReplies: async post => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+
+            const { status, body } = await $xhr.get(
+                `/comments/posts/${post}/replies`,
+                { type: 'json' }
+            );
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+        createPost: async args => {
+            if(typeof args !== 'object' || args === null)
+                throw new Error('args must be a non-null object');
+
+            const { status, body } = await $xhr.post(
+                '/comments/posts',
+                { csrf: true, type: 'json' },
+                args
+            );
+            if(status !== 201)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+        updatePost: async (post, args) => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+            if(typeof args !== 'object' || args === null)
+                throw new Error('args must be a non-null object');
+
+            const { status, body } = await $xhr.post(
+                `/comments/posts/${post}`,
+                { csrf: true, type: 'json' },
+                args
+            );
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+        deletePost: async post => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+
+            const { status, body } = await $xhr.delete(`/comments/posts/${post}`, { csrf: true, type: 'json' });
+            if(status !== 204)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+        },
+        restorePost: async post => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+
+            const { status, body } = await $xhr.post(`/comments/posts/${post}/restore`, { csrf: true, type: 'json' });
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+        },
+        nukePost: async post => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+
+            const { status } = await $xhr.post(`/comments/posts/${post}/nuke`, { csrf: true, type: 'json' });
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+        },
+        createVote: async (post, vote) => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+            if(typeof vote === 'string')
+                vote = parseInt(vote);
+            if(typeof vote !== 'number' || isNaN(vote))
+                throw new Error('vote must be a number');
+
+            const { status, body } = await $xhr.post(
+                `/comments/posts/${post}/vote`,
+                { csrf: true, type: 'json' },
+                { vote }
+            );
+            if(status !== 201)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+        deleteVote: async post => {
+            if(typeof post !== 'string' || post.trim() === '')
+                throw new Error('post is not a valid post id');
+
+            const { status, body } = await $xhr.delete(
+                `/comments/posts/${post}/vote`,
+                { csrf: true, type: 'json' }
+            );
+            if(status !== 200)
+                throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' });
+
+            return body;
+        },
+    };
+})();
diff --git a/assets/misuzu.js/comments/form.jsx b/assets/misuzu.js/comments/form.jsx
new file mode 100644
index 00000000..7be77906
--- /dev/null
+++ b/assets/misuzu.js/comments/form.jsx
@@ -0,0 +1,119 @@
+#include comments/api.js
+
+const MszCommentsFormNotice = function(args) {
+    const { body } = args ?? {};
+    const element = <div class="comments-notice">
+        <div class="comments-notice-inner">{body}</div>
+    </div>;
+
+    return {
+        get element() { return element; },
+    };
+};
+
+const MszCommentsForm = function(args) {
+    const {
+        userInfo, catInfo, postInfo,
+        listing, repliesToggle, replyToggle,
+    } = args ?? {};
+
+    const defaultStatus = () => <>Press <kbd>enter</kbd> to submit, use <kbd>shift</kbd>+<kbd>enter</kbd> to start a new line.</>;
+    const status = <div class="comments-form-status-text">{defaultStatus}</div>;
+
+    const element = <form class={{ 'comments-form': true, 'comments-form-root': !postInfo }} style={`--user-colour: ${userInfo.colour}`}>
+        <input type="hidden" name="category" value={catInfo.name} />
+        {postInfo ? <input type="hidden" name="reply_to" value={postInfo.id} /> : null}
+        <div class="comments-form-avatar">
+            <img src={userInfo.avatar} alt="" width="40" height="40" class="avatar" />
+        </div>
+        <div class="comments-form-wrap">
+            <div class="comments-form-input">
+                <textarea class="input__textarea" name="body" placeholder="Share your extensive insights..." onkeydown={ev => {
+                    if(status.classList.contains('comments-form-status-error')) {
+                        status.classList.remove('comments-form-status-error');
+                        $removeChildren(status);
+                        $appendChild(status, defaultStatus);
+                    }
+
+                    if(ev.key === 'Enter' && !ev.shiftKey) {
+                        ev.preventDefault();
+                        element.requestSubmit();
+                    }
+
+                    if(ev.key === 'p' && ev.altKey) {
+                        ev.preventDefault();
+                        if(element.elements.pin)
+                            element.elements.pin.checked = !element.elements.pin.checked;
+                    }
+                }} />
+            </div>
+            <div class="comments-form-actions">
+                <div class="comments-form-status">{status}</div>
+                {userInfo.can_pin && !postInfo ? <div class="comments-form-pin"><label><input type="checkbox" name="pin" /> Pin</label></div> : null}
+                <div class="comments-form-post"><button class="input__button">Post</button></div>
+            </div>
+        </div>
+    </form>;
+
+    const forAllFields = closure => {
+        for(let i = 0; i < element.elements.length; ++i)
+            closure(element.elements[i]);
+    };
+
+    const setDisabled = state => {
+        element.classList.toggle('comments-form-disabled', state);
+        forAllFields(field => field.disabled = state);
+    };
+
+    element.onsubmit = async ev => {
+        ev.preventDefault();
+
+        if(element.classList.contains('comments-form-disabled'))
+            return;
+
+        setDisabled(true);
+
+        try {
+            const fields = {};
+            forAllFields(field => {
+                if(!field.name)
+                    return;
+
+                if(field.type === 'checkbox') {
+                    if(field.checked)
+                        fields[field.name] = field.value;
+                } else
+                    fields[field.name] = field.value;
+            });
+
+            listing.addPost(catInfo, userInfo, await MszCommentsApi.createPost(fields));
+            listing.reorder();
+            listing.visible = true;
+
+            if(repliesToggle) {
+                repliesToggle.open = true;
+                ++repliesToggle.count;
+            }
+
+            if(replyToggle?.active)
+                replyToggle.click();
+
+            element.elements.body.value = '';
+            if(element.elements.pin)
+                element.elements.pin.checked = false;
+        } catch(ex) {
+            status.classList.add('comments-form-status-error');
+            status.textContent = ex;
+        } finally {
+            setDisabled(false);
+        }
+    };
+
+    return {
+        get element() { return element; },
+
+        focus() {
+            element.elements.body.focus();
+        },
+    };
+};
diff --git a/assets/misuzu.js/comments/init.js b/assets/misuzu.js/comments/init.js
new file mode 100644
index 00000000..9448e182
--- /dev/null
+++ b/assets/misuzu.js/comments/init.js
@@ -0,0 +1,9 @@
+#include comments/section.jsx
+
+const MszCommentsInit = () => {
+    const targets = Array.from($queryAll('.js-comments'));
+    for(const target of targets) {
+        const section = new MszCommentsSection({ category: target.dataset.category });
+        target.replaceWith(section.element);
+    }
+};
diff --git a/assets/misuzu.js/comments/listing.jsx b/assets/misuzu.js/comments/listing.jsx
new file mode 100644
index 00000000..2fa0fbe1
--- /dev/null
+++ b/assets/misuzu.js/comments/listing.jsx
@@ -0,0 +1,652 @@
+#include msgbox.jsx
+#include comments/api.js
+#include comments/form.jsx
+
+const MszCommentsEntryVoteButton = function(args) {
+    const { name, title, icon, vote } = args ?? {};
+
+    let element, counter;
+    const isCast = () => element?.classList.contains('comments-entry-action-vote-cast') === true;
+
+    element = <button class={`comments-entry-action comments-entry-action-vote-${name}`} onclick={() => { vote(isCast()); }}>
+        {icon}
+    </button>;
+
+    return {
+        get element() { return element; },
+
+        get disabled() { return element.disabled; },
+        set disabled(state) {
+            element.disabled = state;
+        },
+
+        get cast() { return isCast(); },
+        set cast(state) {
+            element.classList.toggle('comments-entry-action-vote-cast', state);
+        },
+
+        get count() { return counter ? parseInt(counter.textContent) : 0; },
+        set count(count) {
+            if(count > 0) {
+                if(!counter)
+                    element.appendChild(counter = <span />);
+
+                const formatted = count.toLocaleString();
+                counter.textContent = formatted;
+                element.title = title.replace('$0', formatted).replace('$s', count === 1 ? '' : 's');
+            } else {
+                if(counter) {
+                    element.removeChild(counter);
+                    counter = undefined;
+                }
+
+                element.title = title.replace('$0', 'No').replace('$s', 's');
+            }
+        },
+    };
+};
+
+const MszCommentsEntryVoteActions = function(args) {
+    const { vote } = args ?? {};
+
+    const like = new MszCommentsEntryVoteButton({
+        name: 'like',
+        title: '$0 like$s',
+        icon: <i class="fas fa-chevron-up" />,
+        vote: cast => { vote(cast ? 0 : 1); }
+    });
+    const dislike = new MszCommentsEntryVoteButton({
+        name: 'dislike',
+        title: '$0 dislike$s',
+        icon: <i class="fas fa-chevron-down" />,
+        vote: cast => { vote(cast ? 0 : -1); }
+    });
+
+    const element = <div class="comments-entry-actions-group comments-entry-actions-group-votes">
+        {like}
+        {dislike}
+    </div>;
+
+    return {
+        get element() { return element; },
+
+        get disabled() { return element.classList.contains('comments-entry-actions-group-disabled'); },
+        set disabled(state) {
+            element.classList.toggle('comments-entry-actions-group-disabled', state);
+            like.disabled = dislike.disabled = state;
+        },
+
+        updateVotes(votes) {
+            like.count = votes?.positive ?? 0;
+            like.cast = votes?.vote > 0;
+            dislike.count = Math.abs(votes?.negative ?? 0);
+            dislike.cast = votes?.vote < 0;
+        },
+    };
+};
+
+const MszCommentsEntryReplyToggleButton = function(args) {
+    const { replies, toggle } = args ?? {};
+
+    let icon, counter;
+    const element = <button class="comments-entry-action" title="No replies" onclick={() => { toggle(); }}>
+        {icon = <i class="fas fa-plus" />}
+        {counter = <span />}
+    </button>;
+
+    const setVisible = state => {
+        element.classList.toggle('hidden', !state);
+    };
+    const setOpen = state => {
+        icon.classList.toggle('fa-plus', !state);
+        icon.classList.toggle('fa-minus', state);
+    };
+    const setCount = count => {
+        const formatted = count.toLocaleString();
+        counter.textContent = formatted;
+        element.title = `${count} ${count === 1 ? 'reply' : 'replies'}`;
+        setVisible(count > 0);
+    };
+
+    setCount(Array.isArray(replies) ? replies.length : (replies ?? 0));
+
+    return {
+        get element() { return element; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) { setVisible(state); },
+
+        get count() { return parseInt(counter.textContent); },
+        set count(count) { setCount(count); },
+
+        get open() { return element.classList.contains('fa-plus'); },
+        set open(state) { setOpen(state); },
+    };
+};
+
+const MszCommentsEntryReplyCreateButton = function(args) {
+    const { toggle } = args ?? {};
+
+    const element = <button class="comments-entry-action" title="Reply" onclick={() => { toggle(); }}>
+        <i class="fas fa-reply" />
+        <span>Reply</span>
+    </button>;
+
+    return {
+        get element() { return element; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) { element.classList.toggle('hidden', !state); },
+
+        get active() { return element.classList.contains('comments-entry-action-reply-active'); },
+        set active(state) { element.classList.toggle('comments-entry-action-reply-active', state); },
+
+        click() {
+            element.click();
+        },
+    };
+};
+
+const MszCommentsEntryReplyActions = function(args) {
+    const { replies, toggleReplies, toggleForm } = args ?? {};
+
+    const toggle = new MszCommentsEntryReplyToggleButton({ replies, toggle: toggleReplies });
+    const button = toggleForm ? new MszCommentsEntryReplyCreateButton({ toggle: toggleForm }) : undefined;
+
+    const element = <div class="comments-entry-actions-group comments-entry-actions-group-replies">
+        {toggle}
+        {button}
+    </div>;
+
+    const setVisible = state => {
+        element.classList.toggle('hidden', !state);
+    };
+
+    return {
+        get element() { return element; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) { setVisible(state); },
+
+        get toggle() { return toggle; },
+        get button() { return button; },
+
+        updateVisible() {
+            setVisible(toggle.visible || button?.visible === true);
+        },
+    };
+};
+
+const MszCommentsEntryGeneralButton = function(args) {
+    const { icon, title, action } = args ?? {};
+    const element = <button class="comments-entry-action" title={title} onclick={() => { action(); }}>{icon}</button>;
+
+    return {
+        get element() { return element; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) { element.classList.toggle('hidden', !state); },
+
+        get disabled() { return element.disabled; },
+        set disabled(state) { element.disabled = state; },
+    };
+};
+
+const MszCommentsEntryGeneralActions = function(args) {
+    let deleteButton, restoreButton, nukeButton, pinButton, unpinButton;
+    const element = <div class="comments-entry-actions-group">
+        {deleteButton = args.delete ? new MszCommentsEntryGeneralButton({ icon: <i class="fas fa-trash" />, title: 'Delete', action: args.delete }) : null}
+        {restoreButton = args.restore ? new MszCommentsEntryGeneralButton({ icon: <i class="fas fa-trash-restore" />, title: 'Restore', action: args.restore }) : null}
+        {nukeButton = args.nuke ? new MszCommentsEntryGeneralButton({ icon: <i class="fas fa-radiation-alt" />, title: 'Permanently delete', action: args.nuke }) : null}
+        {pinButton = args.pin ? new MszCommentsEntryGeneralButton({ icon: <i class="fas fa-thumbtack" />, title: 'Pin', action: args.pin }) : null}
+        {unpinButton = args.unpin ? new MszCommentsEntryGeneralButton({ icon: <i class="fas fa-screwdriver" />, title: 'Unpin', action: args.unpin }) : null}
+    </div>;
+
+    return {
+        get element() { return element; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) { element.classList.toggle('hidden', !state); },
+
+        get disabled() { return element.classList.contains('comments-entry-actions-group-disabled'); },
+        set disabled(state) {
+            element.classList.toggle('comments-entry-actions-group-disabled', state);
+            if(deleteButton)
+                deleteButton.disabled = state;
+            if(restoreButton)
+                restoreButton.disabled = state;
+            if(nukeButton)
+                nukeButton.disabled = state;
+            if(pinButton)
+                pinButton.disabled = state;
+            if(unpinButton)
+                unpinButton.disabled = state;
+        },
+
+        get deleteButton() { return deleteButton; },
+        get restoreButton() { return restoreButton; },
+        get nukeButton() { return nukeButton; },
+
+        get deleteVisible() { return deleteButton?.visible === true; },
+        set deleteVisible(state) {
+            if(deleteButton)
+                deleteButton.visible = state;
+            if(restoreButton)
+                restoreButton.visible = !state;
+            if(nukeButton)
+                nukeButton.visible = !state;
+        },
+
+        get pinButton() { return pinButton; },
+        get unpinButton() { return unpinButton; },
+
+        get pinVisible() { return pinButton?.visible === true; },
+        set pinVisible(state) {
+            if(pinButton)
+                pinButton.visible = state;
+            if(unpinButton)
+                unpinButton.visible = !state;
+        },
+    };
+};
+
+const MszCommentsEntryActions = function() {
+    const element = <div class="comments-entry-actions hidden" />;
+
+    const hideIfNoChildren = () => {
+        element.classList.toggle('hidden', element.childElementCount < 1);
+    };
+    hideIfNoChildren();
+
+    return {
+        get element() { return element; },
+
+        appendGroup(group) {
+            element.appendChild(group.element);
+            hideIfNoChildren();
+        },
+    };
+};
+
+const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) {
+    const actions = new MszCommentsEntryActions;
+
+    const voteActions = new MszCommentsEntryVoteActions({
+        vote: async vote => {
+            if(voteActions.disabled)
+                return;
+
+            voteActions.disabled = true;
+            try {
+                voteActions.updateVotes(vote === 0
+                    ? await MszCommentsApi.deleteVote(postInfo.id)
+                    : await MszCommentsApi.createVote(postInfo.id, vote));
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                enableVoteActionsMaybe();
+            }
+        }
+    });
+    actions.appendGroup(voteActions);
+
+    const enableVoteActionsMaybe = () => {
+        voteActions.disabled = !userInfo.can_vote || !!postInfo.deleted || !!catInfo.locked;
+    };
+
+    enableVoteActionsMaybe();
+    voteActions.updateVotes(postInfo);
+
+    const repliesIsArray = Array.isArray(postInfo.replies);
+    const replies = new MszCommentsListing({ hidden: !repliesIsArray });
+    if(repliesIsArray)
+        replies.addPosts(catInfo, userInfo, postInfo.replies);
+
+    const repliesElem = <div class="comments-entry-replies">
+        {replies}
+    </div>;
+
+    let replyForm;
+    let repliesLoaded = replies.loaded;
+    const replyActions = new MszCommentsEntryReplyActions({
+        replies: postInfo.replies,
+        toggleReplies: async () => {
+            replyActions.toggle.open = replies.visible = !replies.visible;
+            if(!repliesLoaded) {
+                repliesLoaded = true;
+                try {
+                    replies.addPosts(catInfo, userInfo, await MszCommentsApi.getPostReplies(postInfo.id));
+                } catch(ex) {
+                    console.error(ex);
+                    replyActions.toggle.open = false;
+                    repliesLoaded = false;
+                }
+            }
+        },
+        toggleForm: userInfo.can_create ? () => {
+            if(replyForm) {
+                replyActions.button.active = false;
+                repliesElem.removeChild(replyForm.element);
+                replyForm = null;
+            } else {
+                replyActions.button.active = true;
+                replyForm = new MszCommentsForm({
+                    userInfo, catInfo, postInfo,
+                    listing: replies,
+                    repliesToggle: replyActions.toggle,
+                    replyToggle: replyActions.button,
+                });
+                $insertBefore(replies.element, replyForm.element);
+                replyForm.focus();
+            }
+        } : null,
+    });
+    actions.appendGroup(replyActions);
+
+    const enableReplyButtonMaybe = () => {
+        if(replyActions.button)
+            replyActions.button.visible = !catInfo.locked && !postInfo.deleted;
+        replyActions.updateVisible();
+    };
+
+    replyActions.toggle.open = replies.visible;
+    enableReplyButtonMaybe();
+
+    const generalActions = new MszCommentsEntryGeneralActions({
+        delete: postInfo.can_delete ? async () => {
+            generalActions.disabled = true;
+            try {
+                if(!await MszShowConfirmBox(`Are you sure you want to delete comment #${postInfo.id}?`, 'Deleting a comment'))
+                    return;
+
+                postInfo.deleted = new Date;
+                await MszCommentsApi.deletePost(postInfo.id);
+                if(generalActions.restoreButton) {
+                    setOptionalTime(deletedElem, new Date, 'commentDeleted');
+                    generalActions.deleteVisible = false;
+                    enableVoteActionsMaybe();
+                    enableReplyButtonMaybe();
+                    listing.reorder();
+                } else
+                    nukeThePost();
+            } catch(ex) {
+                delete postInfo.deleted;
+                console.error(ex);
+            } finally {
+                generalActions.disabled = false;
+            }
+        } : null,
+        restore: postInfo.can_delete_any ? async () => {
+            generalActions.disabled = true;
+            const deleted = postInfo.deleted;
+            try {
+                delete postInfo.deleted;
+                await MszCommentsApi.restorePost(postInfo.id);
+                setOptionalTime(deletedElem, null, 'commentDeleted');
+                generalActions.deleteVisible = true;
+                enableVoteActionsMaybe();
+                enableReplyButtonMaybe();
+                listing.reorder();
+            } catch(ex) {
+                postInfo.deleted = deleted;
+                console.error(ex);
+            } finally {
+                generalActions.disabled = false;
+            }
+        } : null,
+        nuke: postInfo.can_delete_any ? async () => {
+            generalActions.disabled = true;
+            try {
+                await MszCommentsApi.nukePost(postInfo.id);
+                nukeThePost();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                generalActions.disabled = false;
+            }
+        } : null,
+        pin: root && userInfo.can_pin ? async () => {
+            generalActions.disabled = true;
+            try {
+                if(!await MszShowConfirmBox(`Are you sure you want to pin comment #${postInfo.id}?`, 'Pinning a comment'))
+                    return;
+
+                const result = await MszCommentsApi.updatePost(postInfo.id, { pin: 'on' });
+                generalActions.pinVisible = !result.pinned;
+                setOptionalTime(pinnedElem, result.pinned, 'commentPinned');
+                listing.reorder();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                generalActions.disabled = false;
+            }
+        } : null,
+        unpin: root && userInfo.can_pin ? async () => {
+            generalActions.disabled = true;
+            try {
+                const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '' });
+                generalActions.pinVisible = !result.pinned;
+                setOptionalTime(pinnedElem, result.pinned, 'commentPinned');
+                listing.reorder();
+            } catch(ex) {
+                console.error(ex);
+            } finally {
+                generalActions.disabled = false;
+            }
+        } : null,
+    });
+    actions.appendGroup(generalActions);
+
+    generalActions.deleteVisible = !postInfo.deleted;
+    generalActions.pinVisible = !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">
+                {userAvatarElem}
+            </div>
+            <div class="comments-entry-wrap">
+                <div class="comments-entry-meta">
+                    <div class="comments-entry-user">
+                        {userNameElem}
+                    </div>
+                    <div class="comments-entry-time">
+                        <div class="comments-entry-time-icon">&mdash;</div>
+                        {createdTimeElem}
+                    </div>
+                    {editedElem}
+                    {pinnedElem}
+                    {deletedElem}
+                </div>
+                {bodyElem}
+                {actions}
+            </div>
+        </div>
+        {repliesElem}
+    </div>;
+
+    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 && replyActions.toggle.count < 1)
+            listing.element.removeChild(element);
+        else {
+            generalActions.visible = false;
+            generalActions.disabled = true;
+            enableVoteActionsMaybe();
+            enableReplyButtonMaybe();
+            setUserInfo(null);
+            setBody(null);
+            setOptionalTime(deletedElem, true, 'commentDeleted', true, 'deleted');
+            listing.reorder();
+        }
+    };
+
+    return {
+        get element() { return element; },
+
+        updateLocked() {
+            enableVoteActionsMaybe();
+            enableReplyButtonMaybe();
+            replies.updateLocked();
+        },
+    };
+};
+
+const MszCommentsListing = function(options) {
+    let { hidden, root } = options ?? {};
+
+    let loading; // intentionally left as undefined here so the === null is still false
+    const entries = new Map;
+    const element = <div class={{ 'comments-listing': true, 'comments-listing-root': root, 'hidden': hidden }} />;
+
+    const pub = {
+        get element() { return element; },
+        get count() { return loading === null ? element.childElementCount : 0; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) { element.classList.toggle('hidden', !state); },
+
+        get loaded() { return loading === null; },
+
+        reset() {
+            entries.clear();
+            $removeChildren(element);
+            loading = new MszLoading;
+            element.appendChild(loading.element);
+        },
+        removeLoading() {
+            loading?.element.remove();
+            loading = null;
+        },
+
+        reorder() {
+            // this feels yucky but it works
+            const items = Array.from(element.children).sort((a, b) => parseInt(b.dataset.commentOrder) - parseInt(a.dataset.commentOrder));
+            for(const item of items)
+                element.appendChild(item);
+        },
+        updateLocked() {
+            for(const [, value] of entries)
+                value.updateLocked();
+        },
+
+        addPost(catInfo, userInfo, postInfo) {
+            const existing = element.querySelector(`[data-comment="${postInfo.id}"]`);
+            if(existing)
+                element.removeChild(existing);
+
+            const entry = new MszCommentsEntry(catInfo ?? {}, userInfo ?? {}, postInfo, pub, root);
+            entries.set(postInfo.id, entry);
+            element.appendChild(entry.element);
+        },
+        addPosts(catInfo, userInfo, posts) {
+            try {
+                if(!Array.isArray(posts))
+                    throw 'posts must be an array';
+                catInfo ??= {};
+                userInfo ??= {};
+                for(const postInfo of posts)
+                    pub.addPost(catInfo, userInfo, postInfo);
+            } finally {
+                pub.removeLoading();
+            }
+        },
+    };
+
+    return pub;
+};
diff --git a/assets/misuzu.js/comments/options.jsx b/assets/misuzu.js/comments/options.jsx
new file mode 100644
index 00000000..300f4995
--- /dev/null
+++ b/assets/misuzu.js/comments/options.jsx
@@ -0,0 +1,94 @@
+#include msgbox.jsx
+#include comments/api.js
+
+const MszCommentsOptionsLockAction = function(catInfo, reinit) {
+    const element = <button class="comments-options-action"/>;
+
+    const setLocked = state => {
+        $removeChildren(element);
+        if(state) {
+            element.appendChild(<i class="fas fa-unlock" />);
+            element.appendChild(<span>Unlock</span>);
+        } else {
+            element.appendChild(<i class="fas fa-lock" />);
+            element.appendChild(<span>Lock</span>);
+        }
+    };
+
+    setLocked(catInfo.locked);
+
+    element.onclick = async () => {
+        element.disabled = true;
+        try {
+            if(!catInfo.locked && !await MszShowConfirmBox(`Are you sure you want to lock comments category ${catInfo.name}?`, 'Locked a comment section'))
+                return;
+
+            const result = await MszCommentsApi.updateCategory(catInfo.name, { lock: catInfo.locked ? '0' : '1' });
+            if('locked' in result) {
+                if(result.locked)
+                    catInfo.locked = result.locked;
+                else
+                    delete catInfo.locked;
+            }
+
+            setLocked(catInfo.locked);
+            reinit();
+        } catch(ex) {
+            console.error(ex);
+        } finally {
+            element.disabled = false;
+        }
+    };
+
+    return {
+        get element() { return element; },
+    };
+};
+
+const MszCommentsOptionsRetryAction = function(section) {
+    const element = <button class="comments-options-action">
+        <i class="fas fa-sync-alt" />
+        Retry
+    </button>;
+
+    element.onclick = async () => {
+        element.disabled = true;
+        try {
+            await section.reload();
+        } catch(ex) {
+            console.error(ex);
+        } finally {
+            element.disabled = false;
+        }
+    };
+
+    return {
+        get element() { return element; },
+    };
+};
+
+const MszCommentsOptions = function() {
+    const actions = <div class="comments-options-actions" />;
+    const element = <div class="comments-options hidden">
+        {actions}
+    </div>;
+
+    const hideIfNoChildren = () => {
+        element.classList.toggle('hidden', actions.childElementCount < 1);
+    };
+    hideIfNoChildren();
+
+    return {
+        get element() { return element; },
+
+        reset() {
+            $removeChildren(actions);
+            hideIfNoChildren();
+        },
+
+        appendAction(action) {
+            actions.appendChild(action.element);
+            hideIfNoChildren();
+        },
+    };
+};
diff --git a/assets/misuzu.js/comments/section.jsx b/assets/misuzu.js/comments/section.jsx
new file mode 100644
index 00000000..b5e6ebb4
--- /dev/null
+++ b/assets/misuzu.js/comments/section.jsx
@@ -0,0 +1,83 @@
+#include msgbox.jsx
+#include comments/api.js
+#include comments/form.jsx
+#include comments/listing.jsx
+#include comments/options.jsx
+
+const MszCommentsSection = function(args) {
+    let { category: catName } = args ?? {};
+
+    const options = new MszCommentsOptions;
+    const listing = new MszCommentsListing({ root: true });
+    const element = <div class="comments">
+        {options}
+        {listing}
+    </div>;
+
+    let form;
+    let retryAct;
+
+    const clearForm = () => {
+        if(form) {
+            element.removeChild(form.element);
+            form = undefined;
+        }
+    };
+    const setForm = elem => {
+        clearForm();
+        form = elem;
+        $insertBefore(element.firstChild, form.element);
+    };
+    const initForm = (userInfo, catInfo) => {
+        if(!userInfo)
+            setForm(new MszCommentsFormNotice({ body: 'You must be logged in to post comments.' }));
+        else if(!userInfo.can_create)
+            setForm(new MszCommentsFormNotice({ body: 'You are not allowed to comment.' }));
+        else if(catInfo.locked)
+            setForm(new MszCommentsFormNotice({ body: 'This comment section is closed.' }));
+        else
+            setForm(new MszCommentsForm({ userInfo, catInfo, listing }));
+    };
+
+    const pub = {
+        get element() { return element; },
+
+        async reload() {
+            clearForm();
+            listing.reset();
+
+            try {
+                const { user, category, posts } = await MszCommentsApi.getCategory(catName);
+
+                retryAct = undefined;
+                options.reset();
+
+                initForm(user, category);
+
+                if(user?.can_lock)
+                    options.appendAction(new MszCommentsOptionsLockAction(
+                        category,
+                        () => {
+                            initForm(user, category);
+                            listing.updateLocked();
+                        }
+                    ));
+
+                listing.addPosts(category, user, posts);
+            } catch(ex) {
+                console.error(ex);
+                listing.removeLoading();
+
+                form = new MszCommentsFormNotice({ body: 'Failed to load comments.' });
+                $insertBefore(element.firstChild, form.element);
+
+                if(!retryAct)
+                    options.appendAction(retryAct = new MszCommentsOptionsRetryAction(pub));
+            }
+        },
+    };
+
+    pub.reload();
+
+    return pub;
+};
diff --git a/assets/misuzu.js/embed/audio.js b/assets/misuzu.js/embed/audio.js
index fdba11b0..f877640c 100644
--- a/assets/misuzu.js/embed/audio.js
+++ b/assets/misuzu.js/embed/audio.js
@@ -9,41 +9,32 @@ const MszAudioEmbedPlayerEvents = function() {
 };
 
 const MszAudioEmbed = function(player) {
-    const elem = $create({
-        attrs: {
-            classList: ['aembed', 'aembed-' + player.getType()],
-        },
-        child: player,
-    });
+    const element = $element('div', { classList: ['aembed', 'aembed-' + player.getType()] }, player);
 
     return {
-        getElement: function() {
-            return elem;
-        },
+        get element() { return element; },
+        get player() { return player; },
         appendTo: function(target) {
-            target.appendChild(elem);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, elem);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            elem.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, elem);
+            $insertBefore(target, element);
             target.remove();
         },
-        getPlayer: function() {
-            return player;
-        },
     };
 };
 
 const MszAudioEmbedPlayer = function(metadata, options) {
     options = options || {};
 
-    const shouldAutoplay = options.autoplay === undefined || options.autoplay,
-        haveNativeControls = options.nativeControls !== undefined && options.nativeControls;
+    const shouldAutoplay = options.autoplay === undefined || options.autoplay;
+    const haveNativeControls = options.nativeControls !== undefined && options.nativeControls;
 
     const playerAttrs = {
         src: metadata.url,
@@ -58,26 +49,21 @@ const MszAudioEmbedPlayer = function(metadata, options) {
     const watchers = new MszWatchers;
     watchers.define(MszAudioEmbedPlayerEvents());
 
-    const player = $create({
-        tag: 'audio',
-        attrs: playerAttrs,
-    });
+    const element = $element('audio', playerAttrs);
 
     const pub = {
-        getElement: function() {
-            return player;
-        },
+        get element() { return element; },
         appendTo: function(target) {
-            target.appendChild(player);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, player);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            player.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, player);
+            $insertBefore(target, element);
             target.remove();
         },
         getType: function() { return 'external'; },
@@ -86,76 +72,76 @@ const MszAudioEmbedPlayer = function(metadata, options) {
     pub.watch = (name, handler) => watchers.watch(name, handler);
     pub.unwatch = (name, handler) => watchers.unwatch(name, handler);
 
-    player.addEventListener('play', function() { watchers.call('play', pub); });
+    element.addEventListener('play', function() { watchers.call('play', pub); });
 
-    const pPlay = function() { player.play(); };
+    const pPlay = function() { element.play(); };
     pub.play = pPlay;
 
-    const pPause = function() { player.pause(); };
+    const pPause = function() { element.pause(); };
     pub.pause = pPause;
 
     let stopCalled = false;
-    player.addEventListener('pause', function() {
+    element.addEventListener('pause', function() {
         watchers.call(stopCalled ? 'stop' : 'pause', pub);
         stopCalled = false;
     });
 
     const pStop = function() {
         stopCalled = true;
-        player.pause();
-        player.currentTime = 0;
+        element.pause();
+        element.currentTime = 0;
     };
     pub.stop = pStop;
 
-    const pIsPlaying = function() { return !player.paused; };
+    const pIsPlaying = function() { return !element.paused; };
     pub.isPlaying = pIsPlaying;
 
-    const pIsMuted = function() { return player.muted; };
+    const pIsMuted = function() { return element.muted; };
     pub.isMuted = pIsMuted;
 
-    let lastMuteState = player.muted;
-    player.addEventListener('volumechange', function() {
-        if(lastMuteState !== player.muted) {
-            lastMuteState = player.muted;
+    let lastMuteState = element.muted;
+    element.addEventListener('volumechange', function() {
+        if(lastMuteState !== element.muted) {
+            lastMuteState = element.muted;
             watchers.call('mute', pub, [lastMuteState]);
         } else
-            watchers.call('volume', pub, [player.volume]);
+            watchers.call('volume', pub, [element.volume]);
     });
 
-    const pSetMuted = function(state) { player.muted = state; };
+    const pSetMuted = function(state) { element.muted = state; };
     pub.setMuted = pSetMuted;
 
-    const pGetVolume = function() { return player.volume; };
+    const pGetVolume = function() { return element.volume; };
     pub.getVolume = pGetVolume;
 
-    const pSetVolume = function(volume) { player.volume = volume; };
+    const pSetVolume = function(volume) { element.volume = volume; };
     pub.setVolume = pSetVolume;
 
-    const pGetPlaybackRate = function() { return player.playbackRate; };
+    const pGetPlaybackRate = function() { return element.playbackRate; };
     pub.getPlaybackRate = pGetPlaybackRate;
 
-    player.addEventListener('ratechange', function() {
-        watchers.call('rate', pub, [player.playbackRate]);
+    element.addEventListener('ratechange', function() {
+        watchers.call('rate', pub, [element.playbackRate]);
     });
 
-    const pSetPlaybackRate = function(rate) { player.playbackRate = rate; };
+    const pSetPlaybackRate = function(rate) { element.playbackRate = rate; };
     pub.setPlaybackRate = pSetPlaybackRate;
 
     window.addEventListener('durationchange', function() {
-        watchers.call('duration', pub, [player.duration]);
+        watchers.call('duration', pub, [element.duration]);
     });
 
-    const pGetDuration = function() { return player.duration; };
+    const pGetDuration = function() { return element.duration; };
     pub.getDuration = pGetDuration;
 
     window.addEventListener('timeupdate', function() {
-        watchers.call('time', pub, [player.currentTime]);
+        watchers.call('time', pub, [element.currentTime]);
     });
 
-    const pGetTime = function() { return player.currentTime; };
+    const pGetTime = function() { return element.currentTime; };
     pub.getTime = pGetTime;
 
-    const pSeek = function(time) { player.currentTime = time; };
+    const pSeek = function(time) { element.currentTime = time; };
     pub.seek = pSeek;
 
     return pub;
@@ -167,38 +153,32 @@ const MszAudioEmbedPlaceholder = function(metadata, options) {
     if(typeof options.player !== 'function' && typeof options.onclick !== 'function')
         throw 'Neither a player nor an onclick handler were provided.';
 
-    let title = [],
-        album = undefined;
+    let title = [];
+    let album;
     if(metadata.media !== undefined && metadata.media.tags !== undefined) {
         const tags = metadata.media.tags;
 
         if(tags.title !== undefined) {
             if(tags.artist !== undefined) {
-                title.push({
-                    tag: 'span',
-                    attrs: {
-                        className: 'aembedph-info-title-artist',
-                    },
-                    child: tags.artist,
-                });
+                title.push($element(
+                    'span',
+                    { className: 'aembedph-info-title-artist' },
+                    tags.artist,
+                ));
                 title.push(' - ');
             }
 
-            title.push({
-                tag: 'span',
-                attrs: {
-                    className: 'aembedph-info-title-title',
-                },
-                child: tags.title,
-            });
+            title.push($element(
+                'span',
+                { className: 'aembedph-info-title-title' },
+                tags.title,
+            ));
         } else {
-            title.push({
-                tag: 'span',
-                attrs: {
-                    className: 'aembedph-info-title-title',
-                },
-                child: metadata.title,
-            });
+            title.push($element(
+                'span',
+                { className: 'aembedph-info-title-title' },
+                metadata.title,
+            ));
         }
 
         if(tags.album !== undefined && tags.album !== tags.title)
@@ -207,159 +187,131 @@ const MszAudioEmbedPlaceholder = function(metadata, options) {
 
     const infoChildren = [];
 
-    infoChildren.push({
-        tag: 'h1',
-        attrs: {
-            className: 'aembedph-info-title',
-        },
-        child: title,
-    });
+    infoChildren.push($element(
+        'h1',
+        { className: 'aembedph-info-title' },
+        ...title,
+    ));
 
-    infoChildren.push({
-        tags: 'p',
-        attrs: {
-            className: 'aembedph-info-album',
-        },
-        child: album,
-    });
+    infoChildren.push($element(
+        'p',
+        { className: 'aembedph-info-album' },
+        album,
+    ));
 
-    infoChildren.push({
-        tag: 'div',
-        attrs: {
-            className: 'aembedph-info-site',
-        },
-        child: metadata.site_name,
-    });
+    infoChildren.push($element(
+        'div',
+        { className: 'aembedph-info-site' },
+        metadata.site_name,
+    ));
 
     const style = [];
     if(typeof metadata.color !== 'undefined')
         style.push('--aembedph-colour: ' + metadata.color);
 
-    const coverBackground = $create({
-        attrs: {
-            className: 'aembedph-bg',
-        },
-        child: {
-            tag: 'img',
-            attrs: {
-                alt: '',
-                src: metadata.image,
-                onerror: function(ev) {
-                    coverBackground.classList.add('aembedph-bg-none');
-                },
+    const coverBackground = $element(
+        'div',
+        { className: 'aembedph-bg' },
+        $element('img', {
+            alt: '',
+            src: metadata.image,
+            onerror: function(ev) {
+                coverBackground.classList.add('aembedph-bg-none');
             },
-        },
-    });
+        }),
+    );
 
-    const coverPreview = $create({
-        attrs: {
-            className: 'aembedph-info-cover',
-        },
-        child: {
-            tag: 'img',
-            attrs: {
-                alt: '',
-                src: metadata.image,
-                onerror: function(ev) {
-                    coverPreview.classList.add('aembedph-info-cover-none');
-                },
+    const coverPreview = $element(
+        'div',
+        { className: 'aembedph-info-cover' },
+        $element('img', {
+            alt: '',
+            src: metadata.image,
+            onerror: function(ev) {
+                coverPreview.classList.add('aembedph-info-cover-none');
             },
-        },
-    });
+        }),
+    );
 
-    const pub = {};
+    let element;
+    const pub = {
+        get element() { return element; },
+    };
 
-    const elem = $create({
-        attrs: {
+    element = $element(
+        'div',
+        {
             className: ('aembedph aembedph-' + (options.type || 'external')),
             style: style.join(';'),
             title: metadata.title,
         },
-        child: [
-            coverBackground,
-            {
-                attrs: {
-                    className: 'aembedph-fg',
-                },
-                child: [
-                    {
-                        attrs: {
-                            className: 'aembedph-info',
-                        },
-                        child: [
-                            coverPreview,
-                            {
-                                attrs: {
-                                    className: 'aembedph-info-body',
-                                },
-                                child: infoChildren,
-                            }
-                        ],
+        coverBackground,
+        $element(
+            'div',
+            { className: 'aembedph-fg' },
+            $element(
+                'div',
+                { className: 'aembedph-info' },
+                coverPreview,
+                $element(
+                    'div',
+                    { className: 'aembedph-info-body' },
+                    ...infoChildren
+                ),
+            ),
+            $element(
+                'div',
+                {
+                    className: 'aembedph-play',
+                    onclick: function(ev) {
+                        if(ev.target.tagName.toLowerCase() === 'a')
+                            return;
+
+                        if(typeof options.onclick === 'function') {
+                            options.onclick(ev);
+                            return;
+                        }
+
+                        const player = new options.player(metadata, options);
+
+                        const embed = new MszAudioEmbed(player);
+                        if(options.autoembed === undefined || options.autoembed)
+                            embed.replaceElement(element);
+
+                        if(typeof options.onembed === 'function')
+                            options.onembed(embed);
                     },
-                    {
-                        attrs: {
-                            className: 'aembedph-play',
-                            onclick: function(ev) {
-                                if(ev.target.tagName.toLowerCase() === 'a')
-                                    return;
-
-                                if(typeof options.onclick === 'function') {
-                                    options.onclick(ev);
-                                    return;
-                                }
-
-                                const player = new options.player(metadata, options);
-
-                                const embed = new MszAudioEmbed(player);
-                                if(options.autoembed === undefined || options.autoembed)
-                                    embed.replaceElement(elem);
-
-                                if(typeof options.onembed === 'function')
-                                    options.onembed(embed);
-                            },
+                },
+                $element(
+                    'div',
+                    { className: 'aembedph-play-internal', },
+                    $element('i', { className: 'fas fa-play fa-3x fa-fw' }),
+                ),
+                $element(
+                    'div',
+                    { className: 'aembedph-play-external' },
+                    $element(
+                        'a',
+                        {
+                            className: 'aembedph-play-external-link',
+                            href: metadata.url,
+                            target: '_blank',
+                            rel: 'noopener',
                         },
-                        child: [
-                            {
-                                attrs: {
-                                    className: 'aembedph-play-internal',
-                                },
-                                child: {
-                                    tag: 'i',
-                                    attrs: {
-                                        className: 'fas fa-play fa-3x fa-fw',
-                                    },
-                                },
-                            },
-                            {
-                                attrs: {
-                                    className: 'aembedph-play-external',
-                                },
-                                child: {
-                                    tag: 'a',
-                                    attrs: {
-                                        className: 'aembedph-play-external-link',
-                                        href: metadata.url,
-                                        target: '_blank',
-                                        rel: 'noopener',
-                                    },
-                                    child: ('or listen on ' + metadata.site_name + '?')
-                                },
-                            }
-                        ],
-                    }
-                ],
-            },
-        ],
-    });
+                        `or listen on ${metadata.site_name}?`
+                    ),
+                ),
+            ),
+        ),
+    );
 
-    pub.getElement = function() { return elem; };
-    pub.appendTo = function(target) { target.appendChild(elem); };
-    pub.insertBefore = function(ref) { $insertBefore(ref, elem); };
+    pub.appendTo = function(target) { target.appendChild(element); };
+    pub.insertBefore = function(ref) { $insertBefore(ref, element); };
     pub.nuke = function() {
-        elem.remove();
+        element.remove();
     };
     pub.replaceElement = function(target) {
-        $insertBefore(target, elem);
+        $insertBefore(target, element);
         target.remove();
     };
 
diff --git a/assets/misuzu.js/embed/embed.js b/assets/misuzu.js/embed/embed.js
index 08201ad4..bb18dc89 100644
--- a/assets/misuzu.js/embed/embed.js
+++ b/assets/misuzu.js/embed/embed.js
@@ -30,18 +30,7 @@ const MszEmbed = (function() {
                 }
 
                 $removeChildren(target);
-                target.appendChild($create({
-                    tag: 'i',
-                    attrs: {
-                        className: 'fas fa-2x fa-spinner fa-pulse',
-                        style: {
-                            width: '32px',
-                            height: '32px',
-                            lineHeight: '32px',
-                            textAlign: 'center',
-                        },
-                    },
-                }));
+                target.appendChild((new MszLoading({ inline: true })).element);
 
                 if(filtered.has(cleanUrl))
                     filtered.get(cleanUrl).push(target);
@@ -51,16 +40,16 @@ const MszEmbed = (function() {
 
             const replaceWithUrl = function(targets, url) {
                 for(const target of targets) {
-                    let body = $create({
-                        tag: 'a',
-                        attrs: {
+                    let body = $element(
+                        'a',
+                        {
                             className: 'link',
                             href: url,
                             target: '_blank',
                             rel: 'noopener noreferrer',
                         },
-                        child: url
-                    });
+                        url
+                    );
                     $insertBefore(target, body);
                     target.remove();
                 }
diff --git a/assets/misuzu.js/embed/image.js b/assets/misuzu.js/embed/image.js
index 4dddb6bd..6ff983b4 100644
--- a/assets/misuzu.js/embed/image.js
+++ b/assets/misuzu.js/embed/image.js
@@ -1,29 +1,24 @@
 const MszImageEmbed = function(metadata, options, target) {
     options = options || {};
 
-    const image = $create({
-        tag: 'img',
-        attrs: {
-            alt: target.dataset.mszEmbedAlt || '',
-            src: metadata.url,
-        },
+    const element = $element('img', {
+        alt: target.dataset.mszEmbedAlt || '',
+        src: metadata.url,
     });
 
     const pub = {
-        getElement: function() {
-            return image;
-        },
+        get element() { return element; },
         appendTo: function(target) {
-            target.appendChild(image);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, image);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            image.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, image);
+            $insertBefore(target, element);
             target.remove();
         },
         getType: function() { return 'external'; },
diff --git a/assets/misuzu.js/embed/video.js b/assets/misuzu.js/embed/video.js
index 8c30d064..06276d6a 100644
--- a/assets/misuzu.js/embed/video.js
+++ b/assets/misuzu.js/embed/video.js
@@ -45,38 +45,33 @@ const MszVideoConstrainSize = function(w, h, mw, mh) {
 
 const MszVideoEmbed = function(playerOrFrame) {
     const frame = playerOrFrame;
-    const player = 'getPlayer' in frame ? frame.getPlayer() : frame;
+    const player = frame?.player ?? frame;
 
-    const elem = $create({
-        attrs: {
-            classList: ['embed', 'embed-' + player.getType()],
-        },
-        child: frame,
-    });
+    const element = $element(
+        'div',
+        { classList: ['embed', 'embed-' + player.getType()] },
+        frame,
+    );
 
     return {
-        getElement: function() {
-            return elem;
-        },
+        get element() { return element; },
+        get player() { return player; },
         appendTo: function(target) {
-            target.appendChild(elem);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, elem);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            elem.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, elem);
+            $insertBefore(target, element);
             target.remove();
         },
         getFrame: function() {
             return frame;
         },
-        getPlayer: function() {
-            return player;
-        },
     };
 };
 
@@ -91,119 +86,78 @@ const MszVideoEmbedFrame = function(player, options) {
         icoVolQuiet = 'fa-volume-down',
         icoVolLoud = 'fa-volume-up';
 
-    const btnPlayPause = $create({
-        attrs: {},
-        child: {
-            tag: 'i',
-            attrs: {
-                classList: ['fas', 'fa-fw', icoStatePlay],
-            },
+    const btnPlayPause = $element('div', null, $element(
+        'i', { classList: ['fas', 'fa-fw', icoStatePlay] }
+    ));
+
+    const btnStop = $element('div', null, $element(
+        'i', { classList: ['fas', 'fa-fw', icoStateStop] }
+    ));
+
+    const numCurrentTime = $element('div');
+    const sldProgress = $element('div');
+    const numDurationRemaining = $element('div');
+
+    const btnVolMute = $element('div', null, $element(
+        'i', {
+            // isMuted === icoVolMute
+            // vol < 0.01 === icoVolOff
+            // vol < 0.5 === icoVolQuiet
+            // vol < 1.0 === icoVolLoud
+            classList: ['fas', 'fa-fw', icoVolLoud],
         }
-    });
+    ));
 
-    const btnStop = $create({
-        attrs: {},
-        child: {
-            tag: 'i',
-            attrs: {
-                classList: ['fas', 'fa-fw', icoStateStop],
-            },
-        },
-    });
-
-    const numCurrentTime = $create({
-        attrs: {},
-    });
-
-    const sldProgress = $create({
-        attrs: {},
-        child: [],
-    });
-
-    const numDurationRemaining = $create({
-        attrs: {},
-    });
-
-    const btnVolMute = $create({
-        attrs: {},
-        child: {
-            tag: 'i',
-            attrs: {
-                // isMuted === icoVolMute
-                // vol < 0.01 === icoVolOff
-                // vol < 0.5 === icoVolQuiet
-                // vol < 1.0 === icoVolLoud
-                classList: ['fas', 'fa-fw', icoVolLoud],
-            },
-        },
-    });
-
-    const elem = $create({
-        attrs: {
+    const element = $element(
+        'div',
+        {
             className: 'embedvf',
             style: {
                 width: player.getWidth().toString() + 'px',
                 height: player.getHeight().toString() + 'px',
             },
         },
-        child: [
-            {
-                attrs: {
-                    className: 'embedvf-player',
-                },
-                child: player,
-            },
-            {
-                attrs: {
-                    className: 'embedvf-overlay',
-                },
-                child: [
-                    {
-                        attrs: {
-                            className: 'embedvf-controls',
-                        },
-                        child: [
-                            btnPlayPause,
-                            btnStop,
-                            numCurrentTime,
-                            sldProgress,
-                            numDurationRemaining,
-                        ],
-                    },
-                ],
-            },
-        ],
-    });
+        $element('div', { className: 'embedvf-player' }, player),
+        $element(
+            'div',
+            { className: 'embedvf-overlay' },
+            $element(
+                'div',
+                { className: 'embedvf-controls' },
+                btnPlayPause,
+                btnStop,
+                numCurrentTime,
+                sldProgress,
+                numDurationRemaining,
+            ),
+        ),
+    );
 
     return {
-        getElement: function() {
-            return elem;
-        },
+        get element() { return element; },
+        get player() { return player; },
         appendTo: function(target) {
-            target.appendChild(elem);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, elem);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            elem.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, elem);
+            $insertBefore(target, element);
             target.remove();
         },
-        getPlayer: function() {
-            return player;
-        },
     };
 };
 
 const MszVideoEmbedPlayer = function(metadata, options) {
     options = options || {};
 
-    const shouldAutoplay = options.autoplay === undefined || options.autoplay,
-        haveNativeControls = options.nativeControls !== undefined && options.nativeControls,
-        shouldObserveResize = options.observeResize === undefined || options.observeResize;
+    const shouldAutoplay = options.autoplay === undefined || options.autoplay;
+    const haveNativeControls = options.nativeControls !== undefined && options.nativeControls;
+    const shouldObserveResize = options.observeResize === undefined || options.observeResize;
 
     const videoAttrs = {
         src: metadata.url,
@@ -230,32 +184,27 @@ const MszVideoEmbedPlayer = function(metadata, options) {
     const watchers = new MszWatchers;
     watchers.define(MszVideoEmbedPlayerEvents());
 
-    const player = $create({
-        tag: 'video',
-        attrs: videoAttrs,
-    });
+    const element = $element('video', videoAttrs);
 
     const setSize = function(w, h) {
         const size = constrainSize(w, h, initialSize[0], initialSize[1]);
-        player.style.width = size[0].toString() + 'px';
-        player.style.height = size[1].toString() + 'px';
+        element.style.width = size[0].toString() + 'px';
+        element.style.height = size[1].toString() + 'px';
     };
 
     const pub = {
-        getElement: function() {
-            return player;
-        },
+        get element() { return element; },
         appendTo: function(target) {
-            target.appendChild(player);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, player);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            player.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, player);
+            $insertBefore(target, element);
             target.remove();
         },
         getType: function() { return 'external'; },
@@ -267,78 +216,78 @@ const MszVideoEmbedPlayer = function(metadata, options) {
     pub.unwatch = (name, handler) => watchers.unwatch(name, handler);
 
     if(shouldObserveResize)
-        player.addEventListener('resize', function() { setSize(player.videoWidth, player.videoHeight); });
+        element.addEventListener('resize', function() { setSize(element.videoWidth, element.videoHeight); });
 
-    player.addEventListener('play', function() { watchers.call('play'); });
+    element.addEventListener('play', function() { watchers.call('play'); });
 
-    const pPlay = function() { player.play(); };
+    const pPlay = function() { element.play(); };
     pub.play = pPlay;
 
-    const pPause = function() { player.pause(); };
+    const pPause = function() { element.pause(); };
     pub.pause = pPause;
 
     let stopCalled = false;
-    player.addEventListener('pause', function() {
+    element.addEventListener('pause', function() {
         watchers.call(stopCalled ? 'stop' : 'pause');
         stopCalled = false;
     });
 
     const pStop = function() {
         stopCalled = true;
-        player.pause();
-        player.currentTime = 0;
+        element.pause();
+        element.currentTime = 0;
     };
     pub.stop = pStop;
 
-    const pIsPlaying = function() { return !player.paused; };
+    const pIsPlaying = function() { return !element.paused; };
     pub.isPlaying = pIsPlaying;
 
-    const pIsMuted = function() { return player.muted; };
+    const pIsMuted = function() { return element.muted; };
     pub.isMuted = pIsMuted;
 
-    let lastMuteState = player.muted;
-    player.addEventListener('volumechange', function() {
-        if(lastMuteState !== player.muted) {
-            lastMuteState = player.muted;
+    let lastMuteState = element.muted;
+    element.addEventListener('volumechange', function() {
+        if(lastMuteState !== element.muted) {
+            lastMuteState = element.muted;
             watchers.call('mute', lastMuteState);
         } else
-            watchers.call('volume', player.volume);
+            watchers.call('volume', element.volume);
     });
 
-    const pSetMuted = function(state) { player.muted = state; };
+    const pSetMuted = function(state) { element.muted = state; };
     pub.setMuted = pSetMuted;
 
-    const pGetVolume = function() { return player.volume; };
+    const pGetVolume = function() { return element.volume; };
     pub.getVolume = pGetVolume;
 
-    const pSetVolume = function(volume) { player.volume = volume; };
+    const pSetVolume = function(volume) { element.volume = volume; };
     pub.setVolume = pSetVolume;
 
-    const pGetPlaybackRate = function() { return player.playbackRate; };
+    const pGetPlaybackRate = function() { return element.playbackRate; };
     pub.getPlaybackRate = pGetPlaybackRate;
 
-    player.addEventListener('ratechange', function() {
-        watchers.call('rate', player.playbackRate);
+    element.addEventListener('ratechange', function() {
+        watchers.call('rate', element.playbackRate);
     });
 
-    const pSetPlaybackRate = function(rate) { player.playbackRate = rate; };
+    const pSetPlaybackRate = function(rate) { element.playbackRate = rate; };
     pub.setPlaybackRate = pSetPlaybackRate;
 
     window.addEventListener('durationchange', function() {
-        watchers.call('duration', player.duration);
+        watchers.call('duration', element.duration);
     });
 
-    const pGetDuration = function() { return player.duration; };
+    const pGetDuration = function() { return element.duration; };
     pub.getDuration = pGetDuration;
 
     window.addEventListener('timeupdate', function() {
-        watchers.call('time', player.currentTime);
+        watchers.call('time', element.currentTime);
     });
 
-    const pGetTime = function() { return player.currentTime; };
+    const pGetTime = function() { return element.currentTime; };
     pub.getTime = pGetTime;
 
-    const pSeek = function(time) { player.currentTime = time; };
+    const pSeek = function(time) { element.currentTime = time; };
     pub.seek = pSeek;
 
     return pub;
@@ -347,9 +296,9 @@ const MszVideoEmbedPlayer = function(metadata, options) {
 const MszVideoEmbedYouTube = function(metadata, options) {
     options = options || {};
 
-    const ytOrigin = 'https://www.youtube.com',
-        playerId = 'yt-' + $rngs(8),
-        shouldAutoplay = options.autoplay === undefined || options.autoplay;
+    const ytOrigin = 'https://www.youtube.com';
+    const playerId = 'yt-' + $rngs(8);
+    const shouldAutoplay = options.autoplay === undefined || options.autoplay;
 
     let embedUrl = 'https://www.youtube.com/embed/' + metadata.youtube_video_id + '?enablejsapi=1';
 
@@ -376,31 +325,26 @@ const MszVideoEmbedYouTube = function(metadata, options) {
     const watchers = new MszWatchers;
     watchers.define(MszVideoEmbedPlayerEvents());
 
-    const player = $create({
-        tag: 'iframe',
-        attrs: {
-            frameborder: 0,
-            allow: 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture',
-            allowfullscreen: 'allowfullscreen',
-            src: embedUrl,
-        },
+    const element = $element('iframe', {
+        frameborder: 0,
+        allow: 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture',
+        allowfullscreen: 'allowfullscreen',
+        src: embedUrl,
     });
 
     const pub = {
-        getElement: function() {
-            return player;
-        },
+        get element() { return element; },
         appendTo: function(target) {
-            target.appendChild(player);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, player);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            player.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, player);
+            $insertBefore(target, element);
             target.remove();
         },
         getType: function() { return 'youtube'; },
@@ -413,7 +357,7 @@ const MszVideoEmbedYouTube = function(metadata, options) {
     pub.unwatch = (name, handler) => watchers.unwatch(name, handler);
 
     const postMessage = function(data) {
-        player.contentWindow.postMessage(JSON.stringify(data), ytOrigin);
+        element.contentWindow.postMessage(JSON.stringify(data), ytOrigin);
     };
     const postCommand = function(name, args) {
         postMessage({
@@ -532,7 +476,7 @@ const MszVideoEmbedYouTube = function(metadata, options) {
         }
     });
 
-    player.addEventListener('load', function(ev) {
+    element.addEventListener('load', function(ev) {
         postMessage({
             id: playerId,
             event: 'listening',
@@ -559,49 +503,44 @@ const MszVideoEmbedYouTube = function(metadata, options) {
 const MszVideoEmbedNicoNico = function(metadata, options) {
     options = options || {};
 
-    const nndOrigin = 'https://embed.nicovideo.jp',
-        playerId = 'nnd-' + $rngs(8),
-        shouldAutoplay = options.autoplay === undefined || options.autoplay;
+    const nndOrigin = 'https://embed.nicovideo.jp';
+    const playerId = 'nnd-' + $rngs(8);
+    const shouldAutoplay = options.autoplay === undefined || options.autoplay;
 
     let embedUrl = 'https://embed.nicovideo.jp/watch/' + metadata.nicovideo_video_id + '?jsapi=1&playerId=' + playerId;
 
     if(metadata.nicovideo_start_time)
         embedUrl += '&from=' + encodeURIComponent(metadata.nicovideo_start_time);
 
-    let isMuted = undefined,
-        volume = undefined,
-        duration = undefined,
-        currentTime = undefined,
-        isPlaying = false;
+    let isMuted = undefined;
+    let volume = undefined;
+    let duration = undefined;
+    let currentTime = undefined;
+    let isPlaying = false;
 
     const watchers = new MszWatchers;
     watchers.define(MszVideoEmbedPlayerEvents());
 
-    const player = $create({
-        tag: 'iframe',
-        attrs: {
-            frameborder: 0,
-            allow: 'autoplay',
-            allowfullscreen: 'allowfullscreen',
-            src: embedUrl,
-        },
+    const element = $element('iframe', {
+        frameborder: 0,
+        allow: 'autoplay',
+        allowfullscreen: 'allowfullscreen',
+        src: embedUrl,
     });
 
     const pub = {
-        getElement: function() {
-            return player;
-        },
+        get element() { return element; },
         appendTo: function(target) {
-            target.appendChild(player);
+            target.appendChild(element);
         },
         insertBefore: function(ref) {
-            $insertBefore(ref, player);
+            $insertBefore(ref, element);
         },
         nuke: function() {
-            player.remove();
+            element.remove();
         },
         replaceElement(target) {
-            $insertBefore(target, player);
+            $insertBefore(target, element);
             target.remove();
         },
         getType: function() { return 'nicovideo'; },
@@ -617,7 +556,7 @@ const MszVideoEmbedNicoNico = function(metadata, options) {
         if(name === undefined)
             throw 'name must be specified';
 
-        player.contentWindow.postMessage({
+        element.contentWindow.postMessage({
             playerId: playerId,
             sourceConnectorType: 1,
             eventName: name,
@@ -742,35 +681,29 @@ const MszVideoEmbedPlaceholder = function(metadata, options) {
 
     const infoChildren = [];
 
-    infoChildren.push({
-        tag: 'h1',
-        attrs: {
-            className: 'embedph-info-title',
-        },
-        child: metadata.title,
-    });
+    infoChildren.push($element(
+        'h1',
+        { className: 'embedph-info-title' },
+        metadata.title,
+    ));
 
     if(metadata.description) {
         let firstLine = metadata.description.split("\n")[0].trim();
         if(firstLine.length > 300)
             firstLine = firstLine.substring(0, 300).trim() + '...';
 
-        infoChildren.push({
-            tag: 'div',
-            attrs: {
-                className: 'embedph-info-desc',
-            },
-            child: firstLine,
-        });
+        infoChildren.push($element(
+            'div',
+            { className: 'embedph-info-desc' },
+            firstLine,
+        ));
     }
 
-    infoChildren.push({
-        tag: 'div',
-        attrs: {
-            className: 'embedph-info-site',
-        },
-        child: metadata.site_name,
-    });
+    infoChildren.push($element(
+        'div',
+        { className: 'embedph-info-site' },
+        metadata.site_name,
+    ));
 
     const style = [];
     if(typeof metadata.color !== 'undefined')
@@ -788,116 +721,88 @@ const MszVideoEmbedPlaceholder = function(metadata, options) {
         style.push('height: ' + size[1].toString() + 'px');
     }
 
-    const pub = {};
+    let element;
+    const pub = {
+        get element() { return element; },
+    };
 
-    const elem = $create({
-        attrs: {
+    element = $element(
+        'div',
+        {
             className: ('embedph embedph-' + (options.type || 'external')),
             style: style.join(';'),
         },
-        child: [
-            {
-                attrs: {
-                    className: 'embedph-bg',
-                },
-                child: {
-                    tag: 'img',
-                    attrs: {
-                        src: metadata.image,
+        $element(
+            'div',
+            { className: 'embedph-bg' },
+            $element('img', { src: metadata.image }),
+        ),
+        $element(
+            'div',
+            { className: 'embedph-fg' },
+            $element(
+                'div',
+                { className: 'embedph-info' },
+                $element(
+                    'div',
+                    { className: 'embedph-info-wrap' },
+                    $element('div', { className: 'embedph-info-bar' }),
+                    $element('div', { className: 'embedph-info-body' }, ...infoChildren),
+                ),
+            ),
+            $element(
+                'div',
+                {
+                    className: 'embedph-play',
+                    onclick: function(ev) {
+                        if(ev.target.tagName.toLowerCase() === 'a')
+                            return;
+
+                        if(typeof options.onclick === 'function') {
+                            options.onclick(ev);
+                            return;
+                        }
+
+                        const player = new options.player(metadata, options);
+                        let frameOrPlayer = player;
+
+                        if(typeof options.frame === 'function')
+                            frameOrPlayer = new options.frame(player, options);
+
+                        const embed = new MszVideoEmbed(frameOrPlayer);
+                        if(options.autoembed === undefined || options.autoembed)
+                            embed.replaceElement(element);
+
+                        if(typeof options.onembed === 'function')
+                            options.onembed(embed);
                     },
                 },
-            },
-            {
-                attrs: {
-                    className: 'embedph-fg',
-                },
-                child: [
+                $element(
+                    'div',
+                    { className: 'embedph-play-internal' },
+                    $element('i', { className: 'fas fa-play fa-4x fa-fw' }),
+                ),
+                $element(
+                    'a',
                     {
-                        attrs: {
-                            className: 'embedph-info',
-                        },
-                        child: {
-                            attrs: {
-                                className: 'embedph-info-wrap',
-                            },
-                            child: [
-                                {
-                                    attrs: {
-                                        className: 'embedph-info-bar',
-                                    },
-                                },
-                                {
-                                    attrs: {
-                                        className: 'embedph-info-body',
-                                    },
-                                    child: infoChildren,
-                                }
-                            ],
-                        },
+                        className: 'embedph-play-external',
+                        href: metadata.url,
+                        target: '_blank',
+                        rel: 'noopener',
                     },
-                    {
-                        attrs: {
-                            className: 'embedph-play',
-                            onclick: function(ev) {
-                                if(ev.target.tagName.toLowerCase() === 'a')
-                                    return;
+                    `or watch on ${metadata.site_name}?`
+                ),
+            ),
+        ),
+    );
 
-                                if(typeof options.onclick === 'function') {
-                                    options.onclick(ev);
-                                    return;
-                                }
-
-                                const player = new options.player(metadata, options);
-                                let frameOrPlayer = player;
-
-                                if(typeof options.frame === 'function')
-                                    frameOrPlayer = new options.frame(player, options);
-
-                                const embed = new MszVideoEmbed(frameOrPlayer);
-                                if(options.autoembed === undefined || options.autoembed)
-                                    embed.replaceElement(elem);
-
-                                if(typeof options.onembed === 'function')
-                                    options.onembed(embed);
-                            },
-                        },
-                        child: [
-                            {
-                                attrs: {
-                                    className: 'embedph-play-internal',
-                                },
-                                child: {
-                                    tag: 'i',
-                                    attrs: {
-                                        className: 'fas fa-play fa-4x fa-fw',
-                                    },
-                                },
-                            },
-                            {
-                                tag: 'a',
-                                attrs: {
-                                    className: 'embedph-play-external',
-                                    href: metadata.url,
-                                    target: '_blank',
-                                    rel: 'noopener',
-                                },
-                                child: ('or watch on ' + metadata.site_name + '?'),
-                            }
-                        ],
-                    },
-                ],
-            },
-        ],
-    });
-
-    pub.getElement = function() { return elem; };
-    pub.appendTo = function(target) { target.appendChild(elem); };
-    pub.insertBefore = function(ref) { $insertBefore(ref, elem); };
+    pub.appendTo = function(target) { target.appendChild(element); };
+    pub.insertBefore = function(ref) { $insertBefore(ref, element); };
     pub.nuke = function() {
-        elem.remove();
+        element.remove();
     };
     pub.replaceElement = function(target) {
-        $insertBefore(target, elem);
+        $insertBefore(target, element);
         target.remove();
     };
 
diff --git a/assets/misuzu.js/forum/editor.jsx b/assets/misuzu.js/forum/editor.jsx
index 22e58528..53ebde0d 100644
--- a/assets/misuzu.js/forum/editor.jsx
+++ b/assets/misuzu.js/forum/editor.jsx
@@ -9,15 +9,15 @@ const MszForumEditor = function(form) {
     if(!(form instanceof Element))
         throw 'form must be an instance of element';
 
-    const buttonsElem = form.querySelector('.js-forum-posting-buttons'),
-        textElem = form.querySelector('.js-forum-posting-text'),
-        parserElem = form.querySelector('.js-forum-posting-parser'),
-        previewElem = form.querySelector('.js-forum-posting-preview'),
-        modeElem = form.querySelector('.js-forum-posting-mode'),
-        markupActs = form.querySelector('.js-forum-posting-actions');
+    const buttonsElem = form.querySelector('.js-forum-posting-buttons');
+    const textElem = form.querySelector('.js-forum-posting-text');
+    const parserElem = form.querySelector('.js-forum-posting-parser');
+    const previewElem = form.querySelector('.js-forum-posting-preview');
+    const modeElem = form.querySelector('.js-forum-posting-mode');
+    const markupActs = form.querySelector('.js-forum-posting-actions');
 
-    let lastPostText = '',
-        lastPostParser;
+    let lastPostText = '';
+    let lastPostParser;
 
     const eepromClient = new MszEEPROM(peepApp, peepPath);
     const eepromHistory = <div class="eeprom-widget-history-items"/>;
diff --git a/assets/misuzu.js/main.js b/assets/misuzu.js/main.js
index 5f6c5dc9..1322a6a8 100644
--- a/assets/misuzu.js/main.js
+++ b/assets/misuzu.js/main.js
@@ -1,4 +1,5 @@
 #include msgbox.jsx
+#include comments/init.js
 #include embed/embed.js
 #include events/christmas2019.js
 #include events/events.js
@@ -52,7 +53,7 @@
 
         for(const elem of elems)
             elem.addEventListener('keydown', ev => {
-                if((ev.code === 'Enter' || ev.code === 'NumpadEnter') && ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
+                if(ev.key === 'Enter' && ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
                     // hack: prevent forum editor from screaming when using this keycombo
                     //       can probably be done in a less stupid manner
                     MszForumEditorAllowClose = true;
@@ -129,6 +130,7 @@
         MszEmbed.init(`${location.protocol}//uiharu.${location.host}`);
 
         initXhrActions();
+        MszCommentsInit();
 
         // only used by the forum posting form
         initQuickSubmit();
diff --git a/assets/misuzu.js/messages/list.js b/assets/misuzu.js/messages/list.js
index 8a43bb7b..3b171ac8 100644
--- a/assets/misuzu.js/messages/list.js
+++ b/assets/misuzu.js/messages/list.js
@@ -55,7 +55,7 @@ const MsgMessagesList = function(list) {
         },
         removeItem: item => {
             $arrayRemoveValue(items, item);
-            item.getElement().remove();
+            item.element.remove();
             recountSelected();
             watchers.call('select', selectedCount, items.length);
         },
@@ -150,7 +150,7 @@ const MsgMessagesEntry = function(entry) {
 
     return {
         getId: () => msgId,
-        getElement: () => entry,
+        get element() { return entry; },
         isRead: isRead,
         setRead: setRead,
         isSent: isSent,
diff --git a/assets/misuzu.js/messages/messages.js b/assets/misuzu.js/messages/messages.js
index cc4025c5..a58a7ce0 100644
--- a/assets/misuzu.js/messages/messages.js
+++ b/assets/misuzu.js/messages/messages.js
@@ -107,7 +107,11 @@ const MszMessages = () => {
             if(typeof body === 'object' && typeof body.unread === 'number')
                 if(body.unread > 0)
                     for(const msgsUserBtn of msgsUserBtns)
-                        msgsUserBtn.append($create({ child: body.unread.toLocaleString(), attrs: { className: 'header__desktop__user__button__count' } }));
+                        msgsUserBtn.append($element(
+                            'div',
+                            { className: 'header__desktop__user__button__count' },
+                            body.unread.toLocaleString()
+                        ));
         });
 
     const msgsListElem = $query('.js-messages-list');
diff --git a/assets/misuzu.js/messages/recipient.js b/assets/misuzu.js/messages/recipient.js
index 3ebfd9f5..368bde1e 100644
--- a/assets/misuzu.js/messages/recipient.js
+++ b/assets/misuzu.js/messages/recipient.js
@@ -37,7 +37,7 @@ const MszMessagesRecipient = function(element) {
     update().finally(() => nameTimeout = undefined);
 
     return {
-        getElement: () => element,
+        get element() { return element; },
         onUpdate: handler => {
             if(typeof handler !== 'function')
                 throw 'handler must be a function';
diff --git a/assets/misuzu.js/messages/reply.jsx b/assets/misuzu.js/messages/reply.jsx
index 3d41cb7d..05559b88 100644
--- a/assets/misuzu.js/messages/reply.jsx
+++ b/assets/misuzu.js/messages/reply.jsx
@@ -134,7 +134,7 @@ const MszMessagesReply = function(element) {
     });
 
     return {
-        getElement: () => element,
+        get element() { return element; },
         setWarning: text => {
             if(warnElem === undefined || warnText === undefined)
                 return;
diff --git a/assets/oauth2.css/loading.css b/assets/oauth2.css/loading.css
deleted file mode 100644
index 925d9471..00000000
--- a/assets/oauth2.css/loading.css
+++ /dev/null
@@ -1,36 +0,0 @@
-.oauth2-loading {
-    display: flex;
-    justify-content: center;
-    flex-direction: column;
-    min-height: 200px;
-}
-
-.oauth2-loading-frame {
-    display: flex;
-    justify-content: center;
-    flex: 0 0 auto;
-}
-
-.oauth2-loading-icon {
-    display: grid;
-    grid-template-columns: repeat(3, 1fr);
-    grid-template-rows: repeat(3, 1fr);
-    gap: 2px;
-    margin: 20px;
-}
-
-.oauth2-loading-icon-block {
-    background: #fff;
-    width: 20px;
-    height: 20px;
-}
-
-.oauth2-loading-icon-block-hidden {
-    opacity: 0;
-}
-
-.oauth2-loading-text {
-    text-align: center;
-    font-size: 1.2em;
-    line-height: 1.5em;
-}
diff --git a/assets/oauth2.css/main.css b/assets/oauth2.css/main.css
index 8dbcda53..a97e680c 100644
--- a/assets/oauth2.css/main.css
+++ b/assets/oauth2.css/main.css
@@ -63,7 +63,6 @@ a:focus {
     margin: 10px;
 }
 
-@include loading.css;
 @include banner.css;
 @include error.css;
 @include device.css;
diff --git a/assets/oauth2.js/authorise.js b/assets/oauth2.js/authorise.js
index f76f30c4..628e76f8 100644
--- a/assets/oauth2.js/authorise.js
+++ b/assets/oauth2.js/authorise.js
@@ -1,4 +1,3 @@
-#include loading.jsx
 #include app/info.jsx
 #include app/scope.jsx
 #include header/header.js
@@ -30,7 +29,7 @@ const MszOAuth2AuthoriseErrors = Object.freeze({
 
 const MszOAuth2Authorise = async () => {
     const queryParams = new URLSearchParams(window.location.search);
-    const loading = new MszOAuth2Loading('.js-loading');
+    const loading = new MszLoading({ element: '.js-loading', size: 2 });
     const header = new MszOAuth2Header;
 
     const fAuths = document.querySelector('.js-authorise-form');
diff --git a/assets/oauth2.js/verify.js b/assets/oauth2.js/verify.js
index 9921ef84..a2bd54b7 100644
--- a/assets/oauth2.js/verify.js
+++ b/assets/oauth2.js/verify.js
@@ -1,4 +1,3 @@
-#include loading.jsx
 #include app/info.jsx
 #include app/scope.jsx
 #include header/header.js
@@ -6,7 +5,7 @@
 
 const MszOAuth2Verify = () => {
     const queryParams = new URLSearchParams(window.location.search);
-    const loading = new MszOAuth2Loading('.js-loading');
+    const loading = new MszLoading({ element: '.js-loading', size: 2 });
     const header = new MszOAuth2Header;
 
     const fAuths = document.querySelector('.js-verify-authorise');
diff --git a/build.js b/build.js
index 6c9a5ee4..adbae5bb 100644
--- a/build.js
+++ b/build.js
@@ -12,8 +12,12 @@ const fs = require('fs');
         debug: isDebug,
         swc: {
             es: 'es2021',
-            jsx: '$jsx',
+            jsx: '$element',
+            jsxf: '$fragment',
         },
+        housekeep: [
+            pathJoin(__dirname, 'public', 'assets'),
+        ],
     };
 
     const tasks = {
diff --git a/database/2025_02_10_230238_dont_autoupdate_comment_post_edited_field.php b/database/2025_02_10_230238_dont_autoupdate_comment_post_edited_field.php
new file mode 100644
index 00000000..badedf78
--- /dev/null
+++ b/database/2025_02_10_230238_dont_autoupdate_comment_post_edited_field.php
@@ -0,0 +1,39 @@
+<?php
+use Index\Db\DbConnection;
+use Index\Db\Migration\DbMigration;
+
+final class DontAutoupdateCommentPostEditedField_20250210_230238 implements DbMigration {
+    public function migrate(DbConnection $conn): void {
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_comments_posts
+                CHANGE COLUMN comment_edited comment_edited TIMESTAMP NULL DEFAULT NULL AFTER comment_pinned;
+        SQL);
+
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_news_posts
+                DROP FOREIGN KEY news_posts_category_id_foreign,
+                DROP FOREIGN KEY news_posts_user_id_foreign;
+        SQL);
+
+        $conn->execute(<<<SQL
+            ALTER TABLE msz_news_posts
+                DROP COLUMN comment_section_id,
+                DROP INDEX news_posts_comment_section,
+                DROP INDEX news_posts_category_id_foreign,
+                ADD INDEX news_posts_categories_foreign (category_id),
+                DROP INDEX news_posts_user_id_foreign,
+                ADD INDEX news_posts_users_foreign (user_id),
+                DROP FOREIGN KEY news_posts_comment_section,
+                ADD CONSTRAINT news_posts_categories_foreign
+                    FOREIGN KEY (category_id)
+                    REFERENCES msz_news_categories (category_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                ADD CONSTRAINT news_posts_users_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE SET NULL;
+        SQL);
+    }
+}
diff --git a/package-lock.json b/package-lock.json
index cee1e98a..05ba9dda 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,7 @@
   "packages": {
     "": {
       "dependencies": {
-        "@railcomm/assproc": "^1.0.0"
+        "@railcomm/assproc": "^1.1.0"
       }
     },
     "node_modules/@jridgewell/gen-mapping": {
@@ -67,22 +67,22 @@
       }
     },
     "node_modules/@railcomm/assproc": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/@railcomm/assproc/-/assproc-1.0.0.tgz",
-      "integrity": "sha512-u8BQht9di9yps7eVYYXbUaOeHCcbR8dKNLuc/KZ+E4uhPnFJ414WaIMH6h4QsMbDY7tAFehqFims7zM949nHGg==",
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@railcomm/assproc/-/assproc-1.1.0.tgz",
+      "integrity": "sha512-i5dcFv4XtUsJTAT7PB/rqzN3sPnMYOOFvyRyTt6xlfM/+AFhYXHxbhtcq80UsIBpuWDADXctjZ1Qk9x3AYI96A==",
       "license": "BSD-3-Clause",
       "dependencies": {
-        "@swc/core": "^1.5.25",
-        "autoprefixer": "^10.4.19",
-        "cssnano": "^7.0.2",
+        "@swc/core": "^1.10.17",
+        "autoprefixer": "^10.4.20",
+        "cssnano": "^7.0.6",
         "html-minifier-terser": "^7.2.0",
-        "postcss": "^8.4.38"
+        "postcss": "^8.5.2"
       }
     },
     "node_modules/@swc/core": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.11.tgz",
-      "integrity": "sha512-3zGU5y3S20cAwot9ZcsxVFNsSVaptG+dKdmAxORSE3EX7ixe1Xn5kUwLlgIsM4qrwTUWCJDLNhRS+2HLFivcDg==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.17.tgz",
+      "integrity": "sha512-FXZx7jHpiwz4fTuuueWwsvN7VFLSoeS3mcxCTPUNOHs/K2ecaBO+slh5T5Xvt/KGuD2I/2T8G6Zts0maPkt2lQ==",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -97,16 +97,16 @@
         "url": "https://opencollective.com/swc"
       },
       "optionalDependencies": {
-        "@swc/core-darwin-arm64": "1.10.11",
-        "@swc/core-darwin-x64": "1.10.11",
-        "@swc/core-linux-arm-gnueabihf": "1.10.11",
-        "@swc/core-linux-arm64-gnu": "1.10.11",
-        "@swc/core-linux-arm64-musl": "1.10.11",
-        "@swc/core-linux-x64-gnu": "1.10.11",
-        "@swc/core-linux-x64-musl": "1.10.11",
-        "@swc/core-win32-arm64-msvc": "1.10.11",
-        "@swc/core-win32-ia32-msvc": "1.10.11",
-        "@swc/core-win32-x64-msvc": "1.10.11"
+        "@swc/core-darwin-arm64": "1.10.17",
+        "@swc/core-darwin-x64": "1.10.17",
+        "@swc/core-linux-arm-gnueabihf": "1.10.17",
+        "@swc/core-linux-arm64-gnu": "1.10.17",
+        "@swc/core-linux-arm64-musl": "1.10.17",
+        "@swc/core-linux-x64-gnu": "1.10.17",
+        "@swc/core-linux-x64-musl": "1.10.17",
+        "@swc/core-win32-arm64-msvc": "1.10.17",
+        "@swc/core-win32-ia32-msvc": "1.10.17",
+        "@swc/core-win32-x64-msvc": "1.10.17"
       },
       "peerDependencies": {
         "@swc/helpers": "*"
@@ -118,9 +118,9 @@
       }
     },
     "node_modules/@swc/core-darwin-arm64": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.11.tgz",
-      "integrity": "sha512-ZpgEaNcx2e5D+Pd0yZGVbpSrEDOEubn7r2JXoNBf0O85lPjUm3HDzGRfLlV/MwxRPAkwm93eLP4l7gYnc50l3g==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.17.tgz",
+      "integrity": "sha512-LSQhSjESleTc0c45BnVKRacp9Nl4zhJMlV/nmhpFCOv/CqHI5YBDX5c9bPk9jTRNHIf0QH92uTtswt8yN++TCQ==",
       "cpu": [
         "arm64"
       ],
@@ -134,9 +134,9 @@
       }
     },
     "node_modules/@swc/core-darwin-x64": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.11.tgz",
-      "integrity": "sha512-szObinnq2o7spXMDU5pdunmUeLrfV67Q77rV+DyojAiGJI1RSbEQotLOk+ONOLpoapwGUxOijFG4IuX1xiwQ2g==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.17.tgz",
+      "integrity": "sha512-TTaZFS4jLuA3y6+D2HYv4yVGhmjkOGG6KyAwBiJEeoUaazX5MYOyQwaZBPhRGtzHZFrzi4t4jNix4kAkMajPkQ==",
       "cpu": [
         "x64"
       ],
@@ -150,9 +150,9 @@
       }
     },
     "node_modules/@swc/core-linux-arm-gnueabihf": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.11.tgz",
-      "integrity": "sha512-tVE8aXQwd8JUB9fOGLawFJa76nrpvp3dvErjozMmWSKWqtoeO7HV83aOrVtc8G66cj4Vq7FjTE9pOJeV1FbKRw==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.17.tgz",
+      "integrity": "sha512-8P+ESJyGnVdJi0nUcQfxkbTiB/7hnu6N3U72KbvHFBcuroherwzW4DId1XD4RTU2Cjsh1dztZoCcOLY8W9RW1Q==",
       "cpu": [
         "arm"
       ],
@@ -166,9 +166,9 @@
       }
     },
     "node_modules/@swc/core-linux-arm64-gnu": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.11.tgz",
-      "integrity": "sha512-geFkENU5GMEKO7FqHOaw9HVlpQEW10nICoM6ubFc0hXBv8dwRXU4vQbh9s/isLSFRftw1m4jEEWixAnXSw8bxQ==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.17.tgz",
+      "integrity": "sha512-zT21jDQCe+IslzOtw+BD/9ElO/H4qU4fkkOeVQ68PcxuqYS2gwyDxWqa9IGwpzWexYM+Lzi1rAbl/1BM6nGW8Q==",
       "cpu": [
         "arm64"
       ],
@@ -182,9 +182,9 @@
       }
     },
     "node_modules/@swc/core-linux-arm64-musl": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.11.tgz",
-      "integrity": "sha512-2mMscXe/ivq8c4tO3eQSbQDFBvagMJGlalXCspn0DgDImLYTEnt/8KHMUMGVfh0gMJTZ9q4FlGLo7mlnbx99MQ==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.17.tgz",
+      "integrity": "sha512-C2jaW1X+93HscVcesKYgSuZ9GaKqKcQvwvD+q+4JZkaKF4Zopt/aguc6Tmn/nuavRk0WV8yVCpHXoP7lz/2akA==",
       "cpu": [
         "arm64"
       ],
@@ -198,9 +198,9 @@
       }
     },
     "node_modules/@swc/core-linux-x64-gnu": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.11.tgz",
-      "integrity": "sha512-eu2apgDbC4xwsigpl6LS+iyw6a3mL6kB4I+6PZMbFF2nIb1Dh7RGnu70Ai6mMn1o80fTmRSKsCT3CKMfVdeNFg==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.17.tgz",
+      "integrity": "sha512-vfyxqV5gddurG2NVJLemR/68s7GTe0QruozrZiDpNqr9V4VX9t3PadDKMDAvQz6jKrtiqMtshNXQTNRKAKlzFw==",
       "cpu": [
         "x64"
       ],
@@ -214,9 +214,9 @@
       }
     },
     "node_modules/@swc/core-linux-x64-musl": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.11.tgz",
-      "integrity": "sha512-0n+wPWpDigwqRay4IL2JIvAqSKCXv6nKxPig9M7+epAlEQlqX+8Oq/Ap3yHtuhjNPb7HmnqNJLCXT1Wx+BZo0w==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.17.tgz",
+      "integrity": "sha512-8M+nI5MHZGQUnXyfTLsGw85a3oQRXMsFjgMZuOEJO9ZGBIEnYVuWOxENfcP6MmlJmTOW+cJxHnMGhKY+fjcntw==",
       "cpu": [
         "x64"
       ],
@@ -230,9 +230,9 @@
       }
     },
     "node_modules/@swc/core-win32-arm64-msvc": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.11.tgz",
-      "integrity": "sha512-7+bMSIoqcbXKosIVd314YjckDRPneA4OpG1cb3/GrkQTEDXmWT3pFBBlJf82hzJfw7b6lfv6rDVEFBX7/PJoLA==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.17.tgz",
+      "integrity": "sha512-iUeIBFM6c/NwsreLFSAH395Dahc+54mSi0Kq//IrZ2Y16VlqCV7VHdOIMrdAyDoBFUvh0jKuLJPWt+jlKGtSLg==",
       "cpu": [
         "arm64"
       ],
@@ -246,9 +246,9 @@
       }
     },
     "node_modules/@swc/core-win32-ia32-msvc": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.11.tgz",
-      "integrity": "sha512-6hkLl4+3KjP/OFTryWxpW7YFN+w4R689TSPwiII4fFgsFNupyEmLWWakKfkGgV2JVA59L4Oi02elHy/O1sbgtw==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.17.tgz",
+      "integrity": "sha512-lPXYFvkfYIN8HdNmG6dCnQqgA+rOSTgeAjIhGsYCEyLsYkkhF2FQw34OF6PnWawQ6hOdOE9v6Bw3T4enj3Lb6w==",
       "cpu": [
         "ia32"
       ],
@@ -262,9 +262,9 @@
       }
     },
     "node_modules/@swc/core-win32-x64-msvc": {
-      "version": "1.10.11",
-      "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.11.tgz",
-      "integrity": "sha512-kKNE2BGu/La2k2WFHovenqZvGQAHRIU+rd2/6a7D6EiQ6EyimtbhUqjCCZ+N1f5fIAnvM+sMdLiQJq4jdd/oOQ==",
+      "version": "1.10.17",
+      "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.17.tgz",
+      "integrity": "sha512-KrnkFEWpBmxSe8LixhAZXeeUwTNDVukrPeXJ1PiG+pmb5nI989I9J9IQVIgBv+JXXaK+rmiWjlcIkphaDJJEAA==",
       "cpu": [
         "x64"
       ],
@@ -417,9 +417,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001696",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz",
-      "integrity": "sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==",
+      "version": "1.0.30001700",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
+      "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -703,9 +703,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.5.88",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.88.tgz",
-      "integrity": "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw==",
+      "version": "1.5.102",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz",
+      "integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==",
       "license": "ISC"
     },
     "node_modules/entities": {
@@ -884,9 +884,9 @@
       "license": "ISC"
     },
     "node_modules/postcss": {
-      "version": "8.5.1",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
-      "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
+      "version": "8.5.2",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz",
+      "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==",
       "funding": [
         {
           "type": "opencollective",
@@ -912,9 +912,9 @@
       }
     },
     "node_modules/postcss-calc": {
-      "version": "10.1.0",
-      "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.0.tgz",
-      "integrity": "sha512-uQ/LDGsf3mgsSUEXmAt3VsCSHR3aKqtEIkmB+4PhzYwRYOW5MZs/GhCCFpsOtJJkP6EC6uGipbrnaTjqaJZcJw==",
+      "version": "10.1.1",
+      "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.1.1.tgz",
+      "integrity": "sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==",
       "license": "MIT",
       "dependencies": {
         "postcss-selector-parser": "^7.0.0",
@@ -1331,9 +1331,9 @@
       }
     },
     "node_modules/postcss-selector-parser": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.0.0.tgz",
-      "integrity": "sha512-9RbEr1Y7FFfptd/1eEdntyjMwLeghW1bHX9GWjXo19vx4ytPQhANltvVxDggzJl7mnWM+dX28kb6cyS/4iQjlQ==",
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
+      "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
       "license": "MIT",
       "dependencies": {
         "cssesc": "^3.0.0",
@@ -1494,9 +1494,9 @@
       }
     },
     "node_modules/terser": {
-      "version": "5.37.0",
-      "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
-      "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==",
+      "version": "5.39.0",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
+      "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
       "license": "BSD-2-Clause",
       "dependencies": {
         "@jridgewell/source-map": "^0.3.3",
diff --git a/package.json b/package.json
index 83e7da3f..e7846198 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
 {
   "dependencies": {
-    "@railcomm/assproc": "^1.0.0"
+    "@railcomm/assproc": "^1.1.0"
   }
 }
diff --git a/public-legacy/comments.php b/public-legacy/comments.php
deleted file mode 100644
index a5407619..00000000
--- a/public-legacy/comments.php
+++ /dev/null
@@ -1,213 +0,0 @@
-<?php
-namespace Misuzu;
-
-use RuntimeException;
-use Misuzu\Comments\{CommentsCategoryInfo,CommentsPostInfo};
-
-if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
-    die('Script must be called through the Misuzu route dispatcher.');
-
-$redirect = filter_input(INPUT_GET, 'return') ?? $_SERVER['HTTP_REFERER'] ?? $msz->urls->format('index');
-
-if(!Tools::isLocalURL($redirect))
-    Template::displayInfo('Possible request forgery detected.', 403);
-
-if(!CSRF::validateRequest())
-    Template::displayInfo("Couldn't verify this request, please refresh the page and try again.", 403);
-
-if(!$msz->authInfo->loggedIn)
-    Template::displayInfo('You must be logged in to manage comments.', 403);
-
-if($msz->usersCtx->hasActiveBan($msz->authInfo->userInfo))
-    Template::displayInfo('You have been banned, check your profile for more information.', 403);
-
-$perms = $msz->authInfo->getPerms('global');
-
-$commentId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
-$commentMode = (string)filter_input(INPUT_GET, 'm');
-$commentVote = (int)filter_input(INPUT_GET, 'v', FILTER_SANITIZE_NUMBER_INT);
-
-if(!empty($commentId)) {
-    try {
-        $commentInfo = $msz->comments->getPost($commentId);
-    } catch(RuntimeException $ex) {
-        Template::displayInfo('Post not found.', 404);
-    }
-
-    $categoryInfo = $msz->comments->getCategory(postInfo: $commentInfo);
-}
-
-if($commentMode !== 'create' && empty($commentInfo))
-    Template::throwError(400);
-
-switch($commentMode) {
-    case 'pin':
-    case 'unpin':
-        if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo))
-            Template::displayInfo('Comment category not found.', 404);
-        if(!$perms->check(Perm::G_COMMENTS_PIN) && !$categoryInfo->isOwner($msz->authInfo->userInfo))
-            Template::displayInfo("You're not allowed to pin comments.", 403);
-        if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo) || $commentInfo->deleted)
-            Template::displayInfo("This comment doesn't exist!", 400);
-        if($commentInfo->isReply)
-            Template::displayInfo("You can't pin replies!", 400);
-
-        $isPinning = $commentMode === 'pin';
-
-        if($isPinning) {
-            if($commentInfo->pinned)
-                Template::displayInfo('This comment is already pinned.', 400);
-
-            $msz->comments->pinPost($commentInfo);
-        } else {
-            if(!$commentInfo->pinned)
-                Template::displayInfo("This comment isn't pinned yet.", 400);
-
-            $msz->comments->unpinPost($commentInfo);
-        }
-
-        Tools::redirect($redirect . '#comment-' . $commentInfo->id);
-        break;
-
-    case 'vote':
-        if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo))
-            Template::displayInfo('Comment category not found.', 404);
-        if(!$perms->check(Perm::G_COMMENTS_VOTE) && !$categoryInfo->isOwner($msz->authInfo->userInfo))
-            Template::displayInfo("You're not allowed to vote on comments.", 403);
-        if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo) || $commentInfo->deleted)
-            Template::displayInfo("This comment doesn't exist!", 400);
-
-        if($commentVote > 0)
-            $msz->comments->addPostPositiveVote($commentInfo, $msz->authInfo->userInfo);
-        elseif($commentVote < 0)
-            $msz->comments->addPostNegativeVote($commentInfo, $msz->authInfo->userInfo);
-        else
-            $msz->comments->removePostVote($commentInfo, $msz->authInfo->userInfo);
-
-        Tools::redirect($redirect . '#comment-' . $commentInfo->id);
-        break;
-
-    case 'delete':
-        if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo))
-            Template::displayInfo('Comment category not found.', 404);
-
-        $canDelete = $perms->check(Perm::G_COMMENTS_DELETE_OWN | Perm::G_COMMENTS_DELETE_ANY);
-        if(!$canDelete && !$categoryInfo->isOwner($msz->authInfo->userInfo))
-            Template::displayInfo("You're not allowed to delete comments.", 403);
-
-        $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
-        if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo) || $commentInfo->deleted)
-            Template::displayInfo(
-                $canDeleteAny ? 'This comment is already marked for deletion.' : "This comment doesn't exist.",
-                400
-            );
-
-        $isOwnComment = $commentInfo->userId === $msz->authInfo->userInfo->id;
-        $isModAction  = $canDeleteAny && !$isOwnComment;
-
-        if(!$isModAction && !$isOwnComment)
-            Template::displayInfo("You're not allowed to delete comments made by others.", 403);
-
-        $msz->comments->deletePost($commentInfo);
-
-        if($isModAction) {
-            $msz->createAuditLog('COMMENT_ENTRY_DELETE_MOD', [
-                $commentInfo->id,
-                $commentUserId = $commentInfo->userId,
-                '<username>',
-            ]);
-        } else {
-            $msz->createAuditLog('COMMENT_ENTRY_DELETE', [$commentInfo->id]);
-        }
-
-        Tools::redirect($redirect);
-        break;
-
-    case 'restore':
-        if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY))
-            Template::displayInfo("You're not allowed to restore deleted comments.", 403);
-
-        if(!isset($commentInfo) || !($commentInfo instanceof CommentsPostInfo))
-            Template::displayInfo("This comment is probably nuked already.", 404);
-        if(!$commentInfo->deleted)
-            Template::displayInfo("This comment isn't in a deleted state.", 400);
-
-        $msz->comments->restorePost($commentInfo);
-
-        $msz->createAuditLog('COMMENT_ENTRY_RESTORE', [
-            $commentInfo->id,
-            $commentUserId = $commentInfo->userId,
-            '<username>',
-        ]);
-
-        Tools::redirect($redirect . '#comment-' . $commentInfo->id);
-        break;
-
-    case 'create':
-        if(!isset($categoryInfo) || !($categoryInfo instanceof CommentsCategoryInfo))
-            Template::displayInfo('Comment category not found.', 404);
-        if(!$perms->check(Perm::G_COMMENTS_CREATE) && !$categoryInfo->isOwner($msz->authInfo->userInfo))
-            Template::displayInfo("You're not allowed to post comments.", 403);
-        if(empty($_POST['comment']) || !is_array($_POST['comment']))
-            Template::displayInfo('Missing data.', 400);
-
-        try {
-            $categoryId = isset($_POST['comment']['category']) && is_string($_POST['comment']['category'])
-                ? (int)$_POST['comment']['category']
-                : 0;
-            $categoryInfo = $msz->comments->getCategory(categoryId: (string)$categoryId);
-        } catch(RuntimeException $ex) {
-            Template::displayInfo("This comment category doesn't exist.", 404);
-        }
-
-        $canLock = $perms->check(Perm::G_COMMENTS_LOCK);
-        if($categoryInfo->locked && !$canLock)
-            Template::displayInfo('This comment category has been locked.', 403);
-
-        $commentText = !empty($_POST['comment']['text'])  && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : '';
-        $commentReply = (string)(!empty($_POST['comment']['reply']) && is_string($_POST['comment']['reply']) ? (int)$_POST['comment']['reply'] : 0);
-        $commentLock = !empty($_POST['comment']['lock']) && $canLock;
-        $commentPin = !empty($_POST['comment']['pin']) && $perms->check(Perm::G_COMMENTS_PIN);
-
-        if($commentLock) {
-            if($categoryInfo->locked)
-                $msz->comments->unlockCategory($categoryInfo);
-            else
-                $msz->comments->lockCategory($categoryInfo);
-        }
-
-        if(strlen($commentText) > 0) {
-            $commentText = preg_replace("/[\r\n]{2,}/", "\n", $commentText);
-        } else {
-            if($canLock)
-                Template::displayInfo('The action has been processed.', 400);
-            else
-                Template::displayInfo('Your comment is too short.', 400);
-        }
-
-        if(mb_strlen($commentText) > 5000)
-            Template::displayInfo('Your comment is too long.', 400);
-
-        if($commentReply > 0) {
-            try {
-                $parentInfo = $msz->comments->getPost($commentReply);
-            } catch(RuntimeException $ex) {}
-
-            if(!isset($parentInfo) || !($parentInfo instanceof CommentsPostInfo) || $parentInfo->deleted)
-                Template::displayInfo('The comment you tried to reply to does not exist.', 404);
-        }
-
-        $commentInfo = $msz->comments->createPost(
-            $categoryInfo,
-            $parentInfo ?? null,
-            $msz->authInfo->userInfo,
-            $commentText,
-            $commentPin
-        );
-
-        Tools::redirect($redirect . '#comment-' . $commentInfo->id);
-        break;
-
-    default:
-        Template::displayInfo('Not found.', 404);
-}
diff --git a/public-legacy/profile.php b/public-legacy/profile.php
index 729ae48e..60d478f2 100644
--- a/public-legacy/profile.php
+++ b/public-legacy/profile.php
@@ -318,7 +318,7 @@ if($isEditing) {
 $profileStats = new stdClass;
 $profileStats->forum_topic_count = $msz->forumCtx->countTotalUserTopics($userInfo);
 $profileStats->forum_post_count = $msz->forumCtx->countTotalUserPosts($userInfo);
-$profileStats->comments_count = $msz->comments->countPosts(userInfo: $userInfo, deleted: false);
+$profileStats->comments_count = $msz->commentsCtx->posts->countPosts(userInfo: $userInfo, deleted: false);
 
 if(!$viewingAsGuest) {
     Template::set('profile_warnings', iterator_to_array($msz->usersCtx->warnings->getWarningsWithDefaultBacklog($userInfo)));
diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php
index f338ed23..90031b79 100644
--- a/public-legacy/settings/data.php
+++ b/public-legacy/settings/data.php
@@ -140,7 +140,7 @@ if(isset($_POST['action']) && is_string($_POST['action'])) {
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_track',     ['user_id:s', 'topic_id:s', 'forum_id:s', 'track_last_read:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'login_attempts',         ['attempt_id:s', 'user_id:s:n', 'attempt_success:b', 'attempt_remote_addr:a', 'attempt_country:s', 'attempt_created:t', 'attempt_user_agent:s']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'messages',               ['msg_id:s', 'msg_owner_id:s', 'msg_author_id:s:n', 'msg_recipient_id:s:n', 'msg_reply_to:s:n', 'msg_title:s', 'msg_body:s', 'msg_body_format:s', 'msg_created:t', 'msg_sent:t:n', 'msg_read:t:n', 'msg_deleted:t:n'], 'msg_owner_id');
-                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'news_posts',             ['post_id:s', 'category_id:s', 'user_id:s:n', 'comment_section_id:s:n', 'post_featured:b', 'post_title:s', 'post_text:s', 'post_scheduled:t', 'post_created:t', 'post_updated:t', 'post_deleted:t:n']);
+                            $tmpFiles[] = db_to_zip($archive, $userInfo, 'news_posts',             ['post_id:s', 'category_id:s', 'user_id:s:n', 'post_featured:b', 'post_title:s', 'post_text:s', 'post_scheduled:t', 'post_created:t', 'post_updated:t', 'post_deleted:t:n']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_access',          ['acc_id:s', 'app_id:s', 'user_id:s:n', 'acc_token:n', 'acc_scope:s', 'acc_created:t', 'acc_expires:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_authorise',       ['auth_id:s', 'app_id:s', 'user_id:s', 'uri_id:s', 'auth_challenge_code:n', 'auth_challenge_method:s', 'auth_scope:s', 'auth_code:n', 'auth_created:t', 'auth_expires:t']);
                             $tmpFiles[] = db_to_zip($archive, $userInfo, 'oauth2_device',          ['dev_id:s', 'app_id:s', 'user_id:s:n', 'dev_code:n', 'dev_user_code:n', 'dev_interval:i', 'dev_polled:t', 'dev_scope:s', 'dev_approval:s', 'dev_created:t', 'dev_expires:t']);
diff --git a/src/Changelog/ChangelogRoutes.php b/src/Changelog/ChangelogRoutes.php
index cae82737..a34a4870 100644
--- a/src/Changelog/ChangelogRoutes.php
+++ b/src/Changelog/ChangelogRoutes.php
@@ -9,7 +9,7 @@ use Index\Syndication\FeedBuilder;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
 use Misuzu\Auth\AuthInfo;
-use Misuzu\Comments\{CommentsData,CommentsEx};
+use Misuzu\Comments\CommentsContext;
 use Misuzu\Users\UsersContext;
 
 final class ChangelogRoutes implements RouteHandler, UrlSource {
@@ -20,15 +20,10 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
         private UrlRegistry $urls,
         private ChangelogData $changelog,
         private UsersContext $usersCtx,
+        private CommentsContext $commentsCtx,
         private AuthInfo $authInfo,
-        private CommentsData $comments
     ) {}
 
-    private function getCommentsInfo(string $categoryName): object {
-        $comments = new CommentsEx($this->authInfo, $this->comments, $this->usersCtx);
-        return $comments->getCommentsForLayout($categoryName);
-    }
-
     #[HttpGet('/changelog')]
     #[UrlFormat('changelog-index', '/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>'])]
     public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string {
@@ -88,13 +83,18 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
         if(empty($changes))
             return 404;
 
+        if(empty($filterDate))
+            $commentsCategoryName = null;
+        elseif($commentsCategoryName) // should this be run here?
+            $this->commentsCtx->categories->ensureCategoryExists($commentsCategoryName);
+
         return Template::renderRaw('changelog.index', [
             'changelog_infos' => $changes,
             'changelog_date' => $filterDate,
             'changelog_user' => $filterUser,
             'changelog_tags' => $filterTags,
             'changelog_pagination' => $pagination,
-            'comments_info' => empty($filterDate) && $commentsCategoryName !== null ? null : $this->getCommentsInfo($commentsCategoryName),
+            'comments_category_name' => $commentsCategoryName,
         ]);
     }
 
@@ -111,12 +111,14 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
         $tagInfos = $this->changelog->getTags(changeInfo: $changeInfo);
         $userInfo = $changeInfo->userId !== null ? $this->usersCtx->getUserInfo($changeInfo->userId) : null;
 
+        // should this be run here?
+        $this->commentsCtx->categories->ensureCategoryExists($changeInfo->commentsCategoryName);
+
         return Template::renderRaw('changelog.change', [
             'change_info' => $changeInfo,
             'change_tags' => $tagInfos,
             'change_user_info' => $userInfo,
             'change_user_colour' => $this->usersCtx->getUserColour($userInfo),
-            'comments_info' => $this->getCommentsInfo($changeInfo->commentsCategoryName),
         ]);
     }
 
diff --git a/src/Comments/CommentsCategoriesData.php b/src/Comments/CommentsCategoriesData.php
new file mode 100644
index 00000000..ef1d6713
--- /dev/null
+++ b/src/Comments/CommentsCategoriesData.php
@@ -0,0 +1,227 @@
+<?php
+namespace Misuzu\Comments;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\Db\{DbConnection,DbStatement,DbStatementCache};
+use Misuzu\Pagination;
+use Misuzu\Users\UserInfo;
+
+class CommentsCategoriesData {
+    private DbStatementCache $cache;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function countCategories(
+        UserInfo|string|null $owner = null
+    ): int {
+        if($owner instanceof UserInfo)
+            $owner = $owner->id;
+
+        $hasOwner = $owner !== null;
+
+        $query = 'SELECT COUNT(*) FROM msz_comments_categories';
+        if($hasOwner)
+            $query .= ' WHERE user_id = ?';
+
+        $stmt = $this->cache->get($query);
+        $stmt->nextParameter($owner);
+        $stmt->execute();
+
+        $count = 0;
+        $result = $stmt->getResult();
+
+        if($result->next())
+            $count = $result->getInteger(0);
+
+        return $count;
+    }
+
+    /** @return \Iterator<int, CommentsCategoryInfo> */
+    public function getCategories(
+        UserInfo|string|null $owner = null,
+        ?Pagination $pagination = null
+    ): iterable {
+        if($owner instanceof UserInfo)
+            $owner = $owner->id;
+
+        $hasOwner = $owner !== null;
+        $hasPagination = $pagination !== null;
+
+        $query = <<<SQL
+            SELECT category_id, category_name, user_id,
+                UNIX_TIMESTAMP(category_created),
+                UNIX_TIMESTAMP(category_locked)
+            FROM msz_comments_categories
+        SQL;
+        if($hasOwner)
+            $query .= ' WHERE user_id = ?';
+        $query .= ' ORDER BY category_id ASC'; // should order by date but no index on
+        if($hasPagination)
+            $query .= ' LIMIT ? RANGE ?';
+
+        $stmt = $this->cache->get($query);
+
+        if($hasOwner)
+            $stmt->nextParameter($owner);
+        if($hasPagination)
+            $pagination->addToStatement($stmt);
+
+        $stmt->execute();
+
+        return $stmt->getResultIterator(CommentsCategoryInfo::fromResult(...));
+    }
+
+    public function getCategory(
+        ?string $categoryId = null,
+        ?string $name = null,
+        CommentsPostInfo|string|null $postInfo = null
+    ): CommentsCategoryInfo {
+        $hasCategoryId = $categoryId !== null;
+        $hasName = $name !== null;
+        $hasPostInfo = $postInfo !== null;
+
+        if(!$hasCategoryId && !$hasName && !$hasPostInfo)
+            throw new InvalidArgumentException('At least one of the arguments must be set.');
+        // there has got to be a better way to do this
+        if(($hasCategoryId && ($hasName || $hasPostInfo)) || ($hasName && ($hasCategoryId || $hasPostInfo)) || ($hasPostInfo && ($hasCategoryId || $hasName)))
+            throw new InvalidArgumentException('Only one of the arguments may be specified.');
+
+        $query = <<<SQL
+            SELECT category_id, category_name, user_id,
+                UNIX_TIMESTAMP(category_created),
+                UNIX_TIMESTAMP(category_locked)
+            FROM msz_comments_categories
+        SQL;
+        $value = null;
+        if($hasCategoryId) {
+            $query .= ' WHERE category_id = ?';
+            $value = $categoryId;
+        }
+        if($hasName) {
+            $query .= ' WHERE category_name = ?';
+            $value = $name;
+        }
+        if($hasPostInfo) {
+            if($postInfo instanceof CommentsPostInfo) {
+                $query .= ' WHERE category_id = ?';
+                $value = $postInfo->categoryId;
+            } else {
+                $query .= ' WHERE category_id = (SELECT category_id FROM msz_comments_posts WHERE comment_id = ?)';
+                $value = $postInfo;
+            }
+        }
+
+        $stmt = $this->cache->get($query);
+        $stmt->nextParameter($value);
+        $stmt->execute();
+        $result = $stmt->getResult();
+
+        if(!$result->next())
+            throw new RuntimeException('Comments category not found.');
+
+        return CommentsCategoryInfo::fromResult($result);
+    }
+
+    private function createCategoryInternal(
+        string $name,
+        UserInfo|string|null $owner = null,
+    ): DbStatement {
+        if($owner instanceof UserInfo)
+            $owner = $owner->id;
+
+        $name = trim($name);
+        if(empty($name))
+            throw new InvalidArgumentException('$name may not be empty.');
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_comments_categories (
+                category_name, user_id
+            ) VALUES (?, ?)
+        SQL);
+        $stmt->nextParameter($name);
+        $stmt->nextParameter($owner);
+        $stmt->execute();
+
+        return $stmt;
+    }
+
+    public function createCategory(
+        string $name,
+        UserInfo|string|null $owner = null
+    ): CommentsCategoryInfo {
+        return $this->getCategory(
+            categoryId: (string)$this->createCategoryInternal($name, $owner)->lastInsertId,
+        );
+    }
+
+    public function ensureCategoryExists(
+        string $name,
+        UserInfo|string|null $owner = null
+    ): void {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT COUNT(*)
+            FROM msz_comments_categories
+            WHERE category_name = ?
+        SQL);
+        $stmt->nextParameter($name);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('failed to query for the existence of comments category');
+
+        if(!$result->getBoolean(0))
+            $this->createCategoryInternal($name, $owner);
+    }
+
+    public function deleteCategory(CommentsCategoryInfo|string $category): void {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->id;
+
+        $stmt = $this->cache->get(<<<SQL
+            DELETE FROM msz_comments_categories
+            WHERE category_id = ?
+        SQL);
+        $stmt->nextParameter($category);
+        $stmt->execute();
+    }
+
+    public function updateCategory(
+        CommentsCategoryInfo|string $infoOrId,
+        ?string $name = null,
+        ?bool $locked = null,
+        UserInfo|string|false|null $ownerInfo = null
+    ): void {
+        $fields = [];
+        $values = [];
+
+        if($name !== null) {
+            if(trim($name) === '')
+                throw new InvalidArgumentException('$name must be null or a non-empty string.');
+
+            $fields[] = 'category_name = ?';
+            $values[] = $name;
+        }
+
+        if($locked !== null)
+            $fields[] = $locked ? 'category_locked = COALESCE(category_locked, NOW())' : 'category_locked = NULL';
+
+        if($ownerInfo !== null) {
+            if($ownerInfo === false) {
+                $fields[] = 'user_id = NULL';
+            } else {
+                $fields[] = 'user_id = ?';
+                $values[] = $ownerInfo instanceof UserInfo ? $ownerInfo->id : $ownerInfo;
+            }
+        }
+
+        $stmt = $this->cache->get(sprintf('UPDATE msz_comments_categories SET %s WHERE category_id = ?', implode(', ', $fields)));
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->nextParameter($infoOrId instanceof CommentsCategoryInfo ? $infoOrId->id : $infoOrId);
+        $stmt->execute();
+    }
+}
diff --git a/src/Comments/CommentsCategoryInfo.php b/src/Comments/CommentsCategoryInfo.php
index 74fa2df4..8c4ceb9c 100644
--- a/src/Comments/CommentsCategoryInfo.php
+++ b/src/Comments/CommentsCategoryInfo.php
@@ -12,7 +12,6 @@ class CommentsCategoryInfo {
         public private(set) ?string $ownerId,
         public private(set) int $createdTime,
         public private(set) ?int $lockedTime,
-        public private(set) int $commentsCount, // virtual!!
     ) {}
 
     public static function fromResult(DbResult $result): CommentsCategoryInfo {
@@ -22,7 +21,6 @@ class CommentsCategoryInfo {
             ownerId: $result->getStringOrNull(2),
             createdTime: $result->getInteger(3),
             lockedTime: $result->getIntegerOrNull(4),
-            commentsCount: $result->getInteger(5),
         );
     }
 
@@ -37,12 +35,4 @@ class CommentsCategoryInfo {
     public bool $locked {
         get => $this->lockedTime !== null;
     }
-
-    public function isOwner(UserInfo|string $user): bool {
-        if($this->ownerId === null)
-            return false;
-        if($user instanceof UserInfo)
-            $user = $user->id;
-        return $user === $this->ownerId;
-    }
 }
diff --git a/src/Comments/CommentsContext.php b/src/Comments/CommentsContext.php
new file mode 100644
index 00000000..6922cce5
--- /dev/null
+++ b/src/Comments/CommentsContext.php
@@ -0,0 +1,16 @@
+<?php
+namespace Misuzu\Comments;
+
+use Index\Db\DbConnection;
+
+class CommentsContext {
+    public private(set) CommentsCategoriesData $categories;
+    public private(set) CommentsPostsData $posts;
+    public private(set) CommentsVotesData $votes;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->categories = new CommentsCategoriesData($dbConn);
+        $this->posts = new CommentsPostsData($dbConn);
+        $this->votes = new CommentsVotesData($dbConn);
+    }
+}
diff --git a/src/Comments/CommentsData.php b/src/Comments/CommentsData.php
deleted file mode 100644
index 68a6087f..00000000
--- a/src/Comments/CommentsData.php
+++ /dev/null
@@ -1,504 +0,0 @@
-<?php
-namespace Misuzu\Comments;
-
-use InvalidArgumentException;
-use RuntimeException;
-use Index\Db\{DbConnection,DbStatementCache};
-use Misuzu\Pagination;
-use Misuzu\Users\UserInfo;
-
-class CommentsData {
-    private DbStatementCache $cache;
-
-    public function __construct(DbConnection $dbConn) {
-        $this->cache = new DbStatementCache($dbConn);
-    }
-
-    public function countCategories(
-        UserInfo|string|null $owner = null
-    ): int {
-        if($owner instanceof UserInfo)
-            $owner = $owner->id;
-
-        $hasOwner = $owner !== null;
-
-        $query = 'SELECT COUNT(*) FROM msz_comments_categories';
-        if($hasOwner)
-            $query .= ' WHERE user_id = ?';
-
-        $stmt = $this->cache->get($query);
-        $stmt->nextParameter($owner);
-        $stmt->execute();
-
-        $count = 0;
-        $result = $stmt->getResult();
-
-        if($result->next())
-            $count = $result->getInteger(0);
-
-        return $count;
-    }
-
-    /** @return \Iterator<int, CommentsCategoryInfo> */
-    public function getCategories(
-        UserInfo|string|null $owner = null,
-        ?Pagination $pagination = null
-    ): iterable {
-        if($owner instanceof UserInfo)
-            $owner = $owner->id;
-
-        $hasOwner = $owner !== null;
-        $hasPagination = $pagination !== null;
-
-        $query = 'SELECT category_id, category_name, user_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS `category_comments` FROM msz_comments_categories AS cc';
-        if($hasOwner)
-            $query .= ' WHERE user_id = ?';
-        $query .= ' ORDER BY category_id ASC'; // should order by date but no index on
-        if($hasPagination)
-            $query .= ' LIMIT ? RANGE ?';
-
-        $stmt = $this->cache->get($query);
-
-        if($hasOwner)
-            $stmt->nextParameter($owner);
-        if($hasPagination)
-            $pagination->addToStatement($stmt);
-
-        $stmt->execute();
-
-        return $stmt->getResultIterator(CommentsCategoryInfo::fromResult(...));
-    }
-
-    public function getCategory(
-        ?string $categoryId = null,
-        ?string $name = null,
-        CommentsPostInfo|string|null $postInfo = null
-    ): CommentsCategoryInfo {
-        $hasCategoryId = $categoryId !== null;
-        $hasName = $name !== null;
-        $hasPostInfo = $postInfo !== null;
-
-        if(!$hasCategoryId && !$hasName && !$hasPostInfo)
-            throw new InvalidArgumentException('At least one of the arguments must be set.');
-        // there has got to be a better way to do this
-        if(($hasCategoryId && ($hasName || $hasPostInfo)) || ($hasName && ($hasCategoryId || $hasPostInfo)) || ($hasPostInfo && ($hasCategoryId || $hasName)))
-            throw new InvalidArgumentException('Only one of the arguments may be specified.');
-
-        $query = 'SELECT category_id, category_name, user_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS category_comments FROM msz_comments_categories AS cc';
-        $value = null;
-        if($hasCategoryId) {
-            $query .= ' WHERE category_id = ?';
-            $value = $categoryId;
-        }
-        if($hasName) {
-            $query .= ' WHERE category_name = ?';
-            $value = $name;
-        }
-        if($hasPostInfo) {
-            if($postInfo instanceof CommentsPostInfo) {
-                $query .= ' WHERE category_id = ?';
-                $value = $postInfo->categoryId;
-            } else {
-                $query .= ' WHERE category_id = (SELECT category_id FROM msz_comments_posts WHERE comment_id = ?)';
-                $value = $postInfo;
-            }
-        }
-
-        $stmt = $this->cache->get($query);
-        $stmt->nextParameter($value);
-        $stmt->execute();
-        $result = $stmt->getResult();
-
-        if(!$result->next())
-            throw new RuntimeException('Comments category not found.');
-
-        return CommentsCategoryInfo::fromResult($result);
-    }
-
-    public function checkCategoryNameExists(string $name): bool {
-        $stmt = $this->cache->get('SELECT COUNT(*) FROM msz_comments_categories WHERE category_name = ?');
-        $stmt->nextParameter($name);
-        $stmt->execute();
-
-        $count = 0;
-        $result = $stmt->getResult();
-
-        if($result->next())
-            $count = $result->getInteger(0);
-
-        return $count > 0;
-    }
-
-    public function ensureCategory(string $name, UserInfo|string|null $owner = null): CommentsCategoryInfo {
-        if($this->checkCategoryNameExists($name))
-            return $this->getCategory(name: $name);
-        return $this->createCategory($name, $owner);
-    }
-
-    public function createCategory(string $name, UserInfo|string|null $owner = null): CommentsCategoryInfo {
-        if($owner instanceof UserInfo)
-            $owner = $owner->id;
-
-        $name = trim($name);
-        if(empty($name))
-            throw new InvalidArgumentException('$name may not be empty.');
-
-        $stmt = $this->cache->get('INSERT INTO msz_comments_categories (category_name, user_id) VALUES (?, ?)');
-        $stmt->nextParameter($name);
-        $stmt->nextParameter($owner);
-        $stmt->execute();
-
-        return $this->getCategory(categoryId: (string)$stmt->lastInsertId);
-    }
-
-    public function deleteCategory(CommentsCategoryInfo|string $category): void {
-        if($category instanceof CommentsCategoryInfo)
-            $category = $category->id;
-
-        $stmt = $this->cache->get('DELETE FROM msz_comments_categories WHERE category_id = ?');
-        $stmt->nextParameter($category);
-        $stmt->execute();
-    }
-
-    public function updateCategory(
-        CommentsCategoryInfo|string $category,
-        ?string $name = null,
-        bool $updateOwner = false,
-        UserInfo|string|null $owner = null
-    ): void {
-        if($category instanceof CommentsCategoryInfo)
-            $category = $category->id;
-        if($owner instanceof UserInfo)
-            $owner = $owner->id;
-
-        if($name !== null) {
-            $name = trim($name);
-            if(empty($name))
-                throw new InvalidArgumentException('$name may not be empty.');
-        }
-
-        $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_name = COALESCE(?, category_name), user_id = IF(?, ?, user_id) WHERE category_id = ?');
-        $stmt->nextParameter($name);
-        $stmt->nextParameter($updateOwner ? 1 : 0);
-        $stmt->nextParameter($owner ? 1 : 0);
-        $stmt->nextParameter($category);
-        $stmt->execute();
-    }
-
-    public function lockCategory(CommentsCategoryInfo|string $category): void {
-        if($category instanceof CommentsCategoryInfo)
-            $category = $category->id;
-
-        $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_locked = COALESCE(category_locked, NOW()) WHERE category_id = ?');
-        $stmt->nextParameter($category);
-        $stmt->execute();
-    }
-
-    public function unlockCategory(CommentsCategoryInfo|string $category): void {
-        if($category instanceof CommentsCategoryInfo)
-            $category = $category->id;
-
-        $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_locked = NULL WHERE category_id = ?');
-        $stmt->nextParameter($category);
-        $stmt->execute();
-    }
-
-    public function countPosts(
-        CommentsCategoryInfo|string|null $categoryInfo = null,
-        CommentsPostInfo|string|null $parentInfo = null,
-        UserInfo|string|null $userInfo = null,
-        ?bool $replies = null,
-        ?bool $deleted = null
-    ): int {
-        if($categoryInfo instanceof CommentsCategoryInfo)
-            $categoryInfo = $categoryInfo->id;
-        if($parentInfo instanceof CommentsPostInfo)
-            $parentInfo = $parentInfo->id;
-
-        $hasCategoryInfo = $categoryInfo !== null;
-        $hasParentInfo = $parentInfo !== null;
-        $hasUserInfo = $userInfo !== null;
-        $hasReplies = $replies !== null;
-        $hasDeleted = $deleted !== null;
-
-        $args = 0;
-        $query = 'SELECT COUNT(*) FROM msz_comments_posts';
-
-        if($hasParentInfo) {
-            ++$args;
-            $query .= ' WHERE comment_reply_to = ?';
-        }
-        if($hasCategoryInfo)
-            $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
-        if($hasReplies)
-            $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS');
-        if($hasDeleted)
-            $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
-        if($hasUserInfo)
-            $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
-
-        $stmt = $this->cache->get($query);
-        if($hasParentInfo)
-            $stmt->nextParameter($parentInfo);
-        elseif($hasCategoryInfo)
-            $stmt->nextParameter($categoryInfo);
-        if($hasUserInfo)
-            $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
-        $stmt->execute();
-
-        $result = $stmt->getResult();
-        $count = 0;
-
-        if($result->next())
-            $count = $result->getInteger(0);
-
-        return $count;
-    }
-
-    /** @return \Iterator<int, CommentsPostInfo> */
-    public function getPosts(
-        CommentsCategoryInfo|string|null $categoryInfo = null,
-        CommentsPostInfo|string|null $parentInfo = null,
-        UserInfo|string|null $userInfo = null,
-        ?bool $replies = null,
-        ?bool $deleted = null,
-        bool $includeRepliesCount = false,
-        bool $includeVotesCount = false
-    ): iterable {
-        if($categoryInfo instanceof CommentsCategoryInfo)
-            $categoryInfo = $categoryInfo->id;
-        if($parentInfo instanceof CommentsPostInfo)
-            $parentInfo = $parentInfo->id;
-
-        $hasCategoryInfo = $categoryInfo !== null;
-        $hasParentInfo = $parentInfo !== null;
-        $hasUserInfo = $userInfo !== null;
-        $hasReplies = $replies !== null;
-        $hasDeleted = $deleted !== null;
-
-        $args = 0;
-        $query = 'SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, UNIX_TIMESTAMP(comment_created), UNIX_TIMESTAMP(comment_pinned), UNIX_TIMESTAMP(comment_edited), UNIX_TIMESTAMP(comment_deleted)';
-        if($includeRepliesCount)
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_posts AS ccr WHERE ccr.comment_reply_to = cpp.comment_id AND comment_deleted IS NULL) AS `comment_replies`';
-        if($includeVotesCount) {
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id) AS `comment_votes_total`';
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote > 0) AS `comment_votes_positive`';
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote < 0) AS `comment_votes_negative`';
-        }
-        $query .= ' FROM msz_comments_posts AS cpp';
-
-        if($hasParentInfo) {
-            ++$args;
-            $query .= ' WHERE comment_reply_to = ?';
-        }
-        if($hasCategoryInfo)
-            $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
-        if($hasReplies)
-            $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS');
-        if($hasDeleted)
-            $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
-        if($hasUserInfo)
-            $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
-
-        // this should really not be implicit like this
-        if($hasParentInfo)
-            $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created ASC';
-        elseif($hasCategoryInfo)
-            $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created DESC';
-        else
-            $query .= ' ORDER BY comment_created DESC';
-
-        $stmt = $this->cache->get($query);
-        if($hasParentInfo)
-            $stmt->nextParameter($parentInfo);
-        elseif($hasCategoryInfo)
-            $stmt->nextParameter($categoryInfo);
-        if($hasUserInfo)
-            $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
-        $stmt->execute();
-
-        return $stmt->getResultIterator(fn($result) => CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount));
-    }
-
-    public function getPost(
-        string $postId,
-        bool $includeRepliesCount = false,
-        bool $includeVotesCount = false
-    ): CommentsPostInfo {
-        $query = 'SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, UNIX_TIMESTAMP(comment_created), UNIX_TIMESTAMP(comment_pinned), UNIX_TIMESTAMP(comment_edited), UNIX_TIMESTAMP(comment_deleted)';
-        if($includeRepliesCount)
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_posts AS ccr WHERE ccr.comment_reply_to = cpp.comment_id AND comment_deleted IS NULL) AS `comment_replies`';
-        if($includeVotesCount) {
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id) AS `comment_votes_total`';
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote > 0) AS `comment_votes_positive`';
-            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote < 0) AS `comment_votes_negative`';
-        }
-        $query .= ' FROM msz_comments_posts AS cpp WHERE comment_id = ?';
-
-        $stmt = $this->cache->get($query);
-        $stmt->nextParameter($postId);
-        $stmt->execute();
-
-        $result = $stmt->getResult();
-        if(!$result->next())
-            throw new RuntimeException('No comment with that ID exists.');
-
-        return CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount);
-    }
-
-    public function createPost(
-        CommentsCategoryInfo|string|null $category,
-        CommentsPostInfo|string|null $parent,
-        UserInfo|string|null $user,
-        string $body,
-        bool $pin = false
-    ): CommentsPostInfo {
-        if($category instanceof CommentsCategoryInfo)
-            $category = $category->id;
-        if($parent instanceof CommentsPostInfo) {
-            if($category === null)
-                $category = $parent->categoryId;
-            elseif($category !== $parent->categoryId)
-                throw new InvalidArgumentException('$parent belongs to a different category than where this post is attempted to be created.');
-            $parent = $parent->id;
-        }
-        if($category === null)
-            throw new InvalidArgumentException('$category is null; at least a $category or $parent must be specified.');
-        if($user instanceof UserInfo)
-            $user = $user->id;
-        if(empty(trim($body)))
-            throw new InvalidArgumentException('$body may not be empty.');
-
-        $stmt = $this->cache->get('INSERT INTO msz_comments_posts (category_id, user_id, comment_reply_to, comment_text, comment_pinned) VALUES (?, ?, ?, ?, IF(?, NOW(), NULL))');
-        $stmt->nextParameter($category);
-        $stmt->nextParameter($user);
-        $stmt->nextParameter($parent);
-        $stmt->nextParameter($body);
-        $stmt->nextParameter($pin ? 1 : 0);
-        $stmt->execute();
-
-        return $this->getPost((string)$stmt->lastInsertId);
-    }
-
-    public function deletePost(CommentsPostInfo|string $infoOrId): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
-
-        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_deleted = COALESCE(comment_deleted, NOW()) WHERE comment_id = ?');
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
-
-    public function nukePost(CommentsPostInfo|string $infoOrId): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
-
-        $stmt = $this->cache->get('DELETE FROM msz_comments_posts WHERE comment_id = ?');
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
-
-    public function restorePost(CommentsPostInfo|string $infoOrId): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
-
-        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_deleted = NULL WHERE comment_id = ?');
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
-
-    public function editPost(CommentsPostInfo|string $infoOrId, string $body): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
-
-        if(empty(trim($body)))
-            throw new InvalidArgumentException('$body may not be empty.');
-
-        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_text = ?, comment_edited = NOW() WHERE comment_id = ?');
-        $stmt->nextParameter($body);
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
-
-    public function pinPost(CommentsPostInfo|string $infoOrId): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
-
-        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_pinned = COALESCE(comment_pinned, NOW()) WHERE comment_id = ?');
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
-
-    public function unpinPost(CommentsPostInfo|string $infoOrId): void {
-        if($infoOrId instanceof CommentsPostInfo)
-            $infoOrId = $infoOrId->id;
-
-        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_pinned = NULL WHERE comment_id = ?');
-        $stmt->nextParameter($infoOrId);
-        $stmt->execute();
-    }
-
-    public function getPostVote(
-        CommentsPostInfo|string $post,
-        UserInfo|string|null $user
-    ): CommentsPostVoteInfo {
-        if($post instanceof CommentsPostInfo)
-            $post = $post->id;
-        if($user instanceof UserInfo)
-            $user = $user->id;
-
-        // SUM() here makes it so a result row is always returned, albeit with just NULLs
-        $stmt = $this->cache->get('SELECT comment_id, user_id, SUM(comment_vote) FROM msz_comments_votes WHERE comment_id = ? AND user_id = ?');
-        $stmt->nextParameter($post);
-        $stmt->nextParameter($user);
-        $stmt->execute();
-
-        $result = $stmt->getResult();
-        if(!$result->next())
-            throw new RuntimeException('Failed to fetch vote info.');
-
-        return CommentsPostVoteInfo::fromResult($result);
-    }
-
-    public function addPostVote(
-        CommentsPostInfo|string $post,
-        UserInfo|string $user,
-        int $weight
-    ): void {
-        if($weight === 0)
-            return;
-        if($post instanceof CommentsPostInfo)
-            $post = $post->id;
-        if($user instanceof UserInfo)
-            $user = $user->id;
-
-        $stmt = $this->cache->get('REPLACE INTO msz_comments_votes (comment_id, user_id, comment_vote) VALUES (?, ?, ?)');
-        $stmt->nextParameter($post);
-        $stmt->nextParameter($user);
-        $stmt->nextParameter($weight);
-        $stmt->execute();
-    }
-
-    public function addPostPositiveVote(CommentsPostInfo|string $post, UserInfo|string $user): void {
-        $this->addPostVote($post, $user, 1);
-    }
-
-    public function addPostNegativeVote(CommentsPostInfo|string $post, UserInfo|string $user): void {
-        $this->addPostVote($post, $user, -1);
-    }
-
-    public function removePostVote(
-        CommentsPostInfo|string $post,
-        UserInfo|string $user
-    ): void {
-        if($post instanceof CommentsPostInfo)
-            $post = $post->id;
-        if($user instanceof UserInfo)
-            $user = $user->id;
-
-        $stmt = $this->cache->get('DELETE FROM msz_comments_votes WHERE comment_id = ? AND user_id = ?');
-        $stmt->nextParameter($post);
-        $stmt->nextParameter($user);
-        $stmt->execute();
-    }
-}
diff --git a/src/Comments/CommentsEx.php b/src/Comments/CommentsEx.php
deleted file mode 100644
index 4701744a..00000000
--- a/src/Comments/CommentsEx.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-namespace Misuzu\Comments;
-
-use stdClass;
-use RuntimeException;
-use Misuzu\MisuzuContext;
-use Misuzu\Perm;
-use Misuzu\Auth\AuthInfo;
-use Misuzu\Users\UsersContext;
-
-class CommentsEx {
-    public function __construct(
-        private AuthInfo $authInfo,
-        private CommentsData $comments,
-        private UsersContext $usersCtx
-    ) {}
-
-    public function getCommentsForLayout(CommentsCategoryInfo|string $category): object {
-        $info = new stdClass;
-        if(is_string($category))
-            $category = $this->comments->ensureCategory($category);
-
-        $hasUser = $this->authInfo->loggedIn;
-        $info->user = $hasUser ? $this->authInfo->userInfo : null;
-        $info->colour = $this->usersCtx->getUserColour($info->user);
-        $info->perms = $this->authInfo->getPerms('global')->checkMany([
-            'can_post' => Perm::G_COMMENTS_CREATE,
-            'can_delete' => Perm::G_COMMENTS_DELETE_OWN | Perm::G_COMMENTS_DELETE_ANY,
-            'can_delete_any' => Perm::G_COMMENTS_DELETE_ANY,
-            'can_pin' => Perm::G_COMMENTS_PIN,
-            'can_lock' => Perm::G_COMMENTS_LOCK,
-            'can_vote' => Perm::G_COMMENTS_VOTE,
-        ]);
-        $info->category = $category;
-        $info->posts = [];
-
-        $root = $this->comments->getPosts($category, includeRepliesCount: true, includeVotesCount: true, replies: false);
-        foreach($root as $postInfo)
-            $info->posts[] = $this->decorateComment($postInfo);
-
-        return $info;
-    }
-
-    public function decorateComment(CommentsPostInfo $postInfo): object {
-        $userInfo = $postInfo->userId !== null ? $this->usersCtx->getUserInfo($postInfo->userId) : null;
-
-        $info = new stdClass;
-        $info->post = $postInfo;
-        $info->user = $userInfo;
-        $info->colour = $this->usersCtx->getUserColour($userInfo);
-        $info->vote = $this->comments->getPostVote($postInfo, $userInfo);
-        $info->replies = [];
-
-        $root = $this->comments->getPosts(parentInfo: $postInfo, includeRepliesCount: true, includeVotesCount: true);
-        foreach($root as $childInfo)
-            $info->replies[] = $this->decorateComment($childInfo);
-
-        return $info;
-    }
-}
diff --git a/src/Comments/CommentsPostInfo.php b/src/Comments/CommentsPostInfo.php
index 55f17463..3bd5ef48 100644
--- a/src/Comments/CommentsPostInfo.php
+++ b/src/Comments/CommentsPostInfo.php
@@ -13,48 +13,25 @@ class CommentsPostInfo {
         public private(set) string $body,
         public private(set) int $createdTime,
         public private(set) ?int $pinnedTime,
-        public private(set) ?int $updatedTime,
+        public private(set) ?int $editedTime,
         public private(set) ?int $deletedTime,
-        public private(set) int $repliesCount,
-        public private(set) int $votesCount,
-        public private(set) int $votesPositive,
-        public private(set) int $votesNegative,
     ) {}
 
-    public static function fromResult(
-        DbResult $result,
-        bool $includeRepliesCount = false,
-        bool $includeVotesCount = false
-    ): CommentsPostInfo {
-        $args = [];
-        $count = 0;
-
-        $args[] = $result->getString($count); // id
-        $args[] = $result->getString(++$count); // categoryId
-        $args[] = $result->getStringOrNull(++$count); // userId
-        $args[] = $result->getStringOrNull(++$count); // replyingTo
-        $args[] = $result->getString(++$count); // body
-        $args[] = $result->getInteger(++$count); // createdTime
-        $args[] = $result->getIntegerOrNull(++$count); // pinnedTime
-        $args[] = $result->getIntegerOrNull(++$count); // updatedTime
-        $args[] = $result->getIntegerOrNull(++$count); // deletedTime
-
-        $args[] = $includeRepliesCount ? $result->getInteger(++$count) : 0;
-
-        if($includeVotesCount) {
-            $args[] = $result->getInteger(++$count); // votesCount
-            $args[] = $result->getInteger(++$count); // votesPositive
-            $args[] = $result->getInteger(++$count); // votesNegative
-        } else {
-            $args[] = 0;
-            $args[] = 0;
-            $args[] = 0;
-        }
-
-        return new CommentsPostInfo(...$args);
+    public static function fromResult(DbResult $result): CommentsPostInfo {
+        return new CommentsPostInfo(
+            id: $result->getString(0),
+            categoryId: $result->getString(1),
+            userId: $result->getStringOrNull(2),
+            replyingTo: $result->getStringOrNull(3),
+            body: $result->getString(4),
+            createdTime: $result->getInteger(5),
+            pinnedTime: $result->getIntegerOrNull(6),
+            editedTime: $result->getIntegerOrNull(7),
+            deletedTime: $result->getIntegerOrNull(8),
+        );
     }
 
-    public bool $isReply {
+    public bool $reply {
         get => $this->replyingTo !== null;
     }
 
@@ -70,12 +47,12 @@ class CommentsPostInfo {
         get => $this->pinnedTime !== null;
     }
 
-    public ?CarbonImmutable $updatedAt {
-        get => $this->updatedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->updatedTime);
+    public ?CarbonImmutable $editedAt {
+        get => $this->editedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->editedTime);
     }
 
     public bool $edited {
-        get => $this->updatedTime !== null;
+        get => $this->editedTime !== null;
     }
 
     public ?CarbonImmutable $deletedAt {
diff --git a/src/Comments/CommentsPostsData.php b/src/Comments/CommentsPostsData.php
new file mode 100644
index 00000000..0ecf9455
--- /dev/null
+++ b/src/Comments/CommentsPostsData.php
@@ -0,0 +1,270 @@
+<?php
+namespace Misuzu\Comments;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\Db\{DbConnection,DbStatementCache};
+use Misuzu\Users\UserInfo;
+
+class CommentsPostsData {
+    private DbStatementCache $cache;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function countPosts(
+        CommentsCategoryInfo|string|null $categoryInfo = null,
+        ?string $categoryName = null,
+        CommentsPostInfo|string|null $parentInfo = null,
+        UserInfo|string|null $userInfo = null,
+        ?bool $replies = null,
+        ?bool $deleted = null
+    ): int {
+        if($categoryInfo instanceof CommentsCategoryInfo)
+            $categoryInfo = $categoryInfo->id;
+        if($parentInfo instanceof CommentsPostInfo)
+            $parentInfo = $parentInfo->id;
+
+        $hasCategoryInfo = $categoryInfo !== null;
+        $hasCategoryName = $categoryName !== null;
+        $hasParentInfo = $parentInfo !== null;
+        $hasUserInfo = $userInfo !== null;
+        $hasReplies = $replies !== null;
+        $hasDeleted = $deleted !== null;
+
+        $args = 0;
+        $query = 'SELECT COUNT(*) FROM msz_comments_posts';
+
+        if($hasParentInfo) {
+            ++$args;
+            $query .= ' WHERE comment_reply_to = ?';
+        }
+        if($hasCategoryInfo)
+            $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
+        if($hasCategoryName)
+            $query .= sprintf(' %s category_id = (SELECT category_id FROM msz_comments_categories WHERE category_name = ?)', ++$args > 1 ? 'AND' : 'WHERE');
+        if($hasReplies)
+            $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS');
+        if($hasDeleted)
+            $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
+        if($hasUserInfo)
+            $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
+
+        $stmt = $this->cache->get($query);
+        if($hasParentInfo)
+            $stmt->nextParameter($parentInfo);
+        elseif($hasCategoryInfo)
+            $stmt->nextParameter($categoryInfo);
+        if($hasCategoryName)
+            $stmt->nextParameter($categoryName);
+        if($hasUserInfo)
+            $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        $count = 0;
+
+        if($result->next())
+            $count = $result->getInteger(0);
+
+        return $count;
+    }
+
+    /** @return \Iterator<int, CommentsPostInfo> */
+    public function getPosts(
+        CommentsCategoryInfo|string|null $categoryInfo = null,
+        ?string $categoryName = null,
+        CommentsPostInfo|string|null $parentInfo = null,
+        UserInfo|string|null $userInfo = null,
+        ?bool $replies = null,
+        ?bool $deleted = null
+    ): iterable {
+        if($categoryInfo instanceof CommentsCategoryInfo)
+            $categoryInfo = $categoryInfo->id;
+        if($parentInfo instanceof CommentsPostInfo)
+            $parentInfo = $parentInfo->id;
+
+        $hasCategoryInfo = $categoryInfo !== null;
+        $hasCategoryName = $categoryName !== null;
+        $hasParentInfo = $parentInfo !== null;
+        $hasUserInfo = $userInfo !== null;
+        $hasReplies = $replies !== null;
+        $hasDeleted = $deleted !== null;
+
+        $args = 0;
+        $query = <<<SQL
+            SELECT comment_id, category_id, user_id, comment_reply_to, comment_text,
+                UNIX_TIMESTAMP(comment_created),
+                UNIX_TIMESTAMP(comment_pinned),
+                UNIX_TIMESTAMP(comment_edited),
+                UNIX_TIMESTAMP(comment_deleted)
+            FROM msz_comments_posts
+        SQL;
+
+        if($hasParentInfo) {
+            ++$args;
+            $query .= ' WHERE comment_reply_to = ?';
+        }
+        if($hasCategoryInfo)
+            $query .= sprintf(' %s category_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
+        if($hasCategoryName)
+            $query .= sprintf(' %s category_id = (SELECT category_id FROM msz_comments_categories WHERE category_name = ?)', ++$args > 1 ? 'AND' : 'WHERE');
+        if($hasReplies)
+            $query .= sprintf(' %s comment_reply_to %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $replies ? 'IS NOT' : 'IS');
+        if($hasDeleted)
+            $query .= sprintf(' %s comment_deleted %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $deleted ? 'IS NOT' : 'IS');
+        if($hasUserInfo)
+            $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
+
+        // this should really not be implicit like this
+        if($hasParentInfo)
+            $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created ASC';
+        elseif($hasCategoryInfo)
+            $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created DESC';
+        else
+            $query .= ' ORDER BY comment_created DESC';
+
+        $stmt = $this->cache->get($query);
+        if($hasParentInfo)
+            $stmt->nextParameter($parentInfo);
+        elseif($hasCategoryInfo)
+            $stmt->nextParameter($categoryInfo);
+        if($hasCategoryName)
+            $stmt->nextParameter($categoryName);
+        if($hasUserInfo)
+            $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->execute();
+
+        return $stmt->getResultIterator(CommentsPostInfo::fromResult(...));
+    }
+
+    public function getPost(string $postId): CommentsPostInfo {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT comment_id, category_id, user_id, comment_reply_to, comment_text,
+                UNIX_TIMESTAMP(comment_created),
+                UNIX_TIMESTAMP(comment_pinned),
+                UNIX_TIMESTAMP(comment_edited),
+                UNIX_TIMESTAMP(comment_deleted)
+            FROM msz_comments_posts
+            WHERE comment_id = ?
+        SQL);
+        $stmt->nextParameter($postId);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('No comment with that ID exists.');
+
+        return CommentsPostInfo::fromResult($result);
+    }
+
+    public function createPost(
+        CommentsCategoryInfo|string|null $category,
+        CommentsPostInfo|string|null $parent,
+        UserInfo|string|null $user,
+        string $body,
+        bool $pin = false
+    ): CommentsPostInfo {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->id;
+        if($parent instanceof CommentsPostInfo) {
+            if($category === null)
+                $category = $parent->categoryId;
+            elseif($category !== $parent->categoryId)
+                throw new InvalidArgumentException('$parent belongs to a different category than where this post is attempted to be created.');
+            $parent = $parent->id;
+        }
+        if($category === null)
+            throw new InvalidArgumentException('$category is null; at least a $category or $parent must be specified.');
+        if($user instanceof UserInfo)
+            $user = $user->id;
+        if(empty(trim($body)))
+            throw new InvalidArgumentException('$body may not be empty.');
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_comments_posts (
+                category_id, user_id, comment_reply_to, comment_text, comment_pinned
+            ) VALUES (?, ?, ?, ?, IF(?, NOW(), NULL))
+        SQL);
+        $stmt->nextParameter($category);
+        $stmt->nextParameter($user);
+        $stmt->nextParameter($parent);
+        $stmt->nextParameter($body);
+        $stmt->nextParameter($pin ? 1 : 0);
+        $stmt->execute();
+
+        return $this->getPost((string)$stmt->lastInsertId);
+    }
+
+    public function deletePost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->id;
+
+        $stmt = $this->cache->get(<<<SQL
+            UPDATE msz_comments_posts
+            SET comment_deleted = COALESCE(comment_deleted, NOW())
+            WHERE comment_id = ?
+        SQL);
+        $stmt->nextParameter($infoOrId);
+        $stmt->execute();
+    }
+
+    public function nukePost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->id;
+
+        $stmt = $this->cache->get(<<<SQL
+            DELETE FROM msz_comments_posts
+            WHERE comment_id = ?
+        SQL);
+        $stmt->nextParameter($infoOrId);
+        $stmt->execute();
+    }
+
+    public function restorePost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->id;
+
+        $stmt = $this->cache->get(<<<SQL
+            UPDATE msz_comments_posts
+            SET comment_deleted = NULL
+            WHERE comment_id = ?
+        SQL);
+        $stmt->nextParameter($infoOrId);
+        $stmt->execute();
+    }
+
+    public function updatePost(
+        CommentsPostInfo|string $infoOrId,
+        ?string $body,
+        ?bool $pinned,
+        bool $edited = false
+    ): void {
+        $fields = [];
+        $values = [];
+
+        if($body !== null) {
+            if(trim($body) === '')
+                throw new InvalidArgumentException('$body must be null or a non-empty string.');
+
+            $fields[] = 'comment_text = ?';
+            $values[] = $body;
+        }
+
+        if($pinned !== null)
+            $fields[] = $pinned ? 'comment_pinned = COALESCE(comment_pinned, NOW())' : 'comment_pinned = NULL';
+
+        if($edited)
+            $fields[] = 'comment_edited = NOW()';
+
+        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 instanceof CommentsPostInfo ? $infoOrId->id : $infoOrId);
+        $stmt->execute();
+    }
+}
diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php
new file mode 100644
index 00000000..e03496f7
--- /dev/null
+++ b/src/Comments/CommentsRoutes.php
@@ -0,0 +1,567 @@
+<?php
+namespace Misuzu\Comments;
+
+use RuntimeException;
+use Index\XArray;
+use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
+use Index\Http\Routing\{HttpDelete,HttpGet,HttpMiddleware,HttpPatch,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
+use Misuzu\{CSRF,Perm};
+use Misuzu\Auth\AuthInfo;
+use Misuzu\Perms\{PermissionResult,IPermissionResult};
+use Misuzu\Users\{UserInfo,UsersContext,UsersData};
+
+class CommentsRoutes implements RouteHandler, UrlSource {
+    use RouteHandlerCommon, UrlSourceCommon;
+
+    public function __construct(
+        private CommentsContext $commentsCtx,
+        private UsersContext $usersCtx,
+        private UrlRegistry $urls,
+        private AuthInfo $authInfo,
+    ) {}
+
+    private function getGlobalPerms(): IPermissionResult {
+        return $this->authInfo->loggedIn && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo)
+            ? $this->authInfo->getPerms('global')
+            : new PermissionResult(0);
+    }
+
+    private function convertUser(UserInfo $userInfo, int $avatarRes = 80): array {
+        $user = [
+            'id' => $userInfo->id,
+            'name' => $userInfo->name,
+            'profile' => $this->urls->format('user-profile', ['user' => $userInfo->id]),
+            'avatar' => $this->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => $avatarRes]),
+        ];
+
+        $userColour = $this->usersCtx->getUserColour($userInfo);
+        if(!$userColour->inherits)
+            $user['colour'] = (string)$userColour;
+
+        return $user;
+    }
+
+    private function convertPosts(
+        IPermissionResult $perms,
+        CommentsCategoryInfo $catInfo,
+        iterable $postInfos,
+        bool $loadReplies = false
+    ): array {
+        $posts = [];
+
+        foreach($postInfos as $postInfo) {
+            $post = $this->convertPost(
+                $perms,
+                $catInfo,
+                $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,
+        CommentsCategoryInfo $catInfo,
+        CommentsPostInfo $postInfo,
+        ?iterable $replyInfos = null
+    ): array {
+        $canViewDeleted = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
+        $isDeleted = $postInfo->deleted && !$canViewDeleted;
+
+        $post = [
+            'id' => $postInfo->id,
+            'created' => $postInfo->createdAt->toIso8601ZuluString(),
+        ];
+        if(!$isDeleted) {
+            $post['body'] = $postInfo->body;
+            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 || $catInfo->ownerId === $this->authInfo->userId) && $perms->check(Perm::G_COMMENTS_DELETE_OWN))
+                    $post['can_delete'] = true;
+            }
+        }
+        if($postInfo->deleted)
+            $post['deleted'] = $canViewDeleted ? $postInfo->deletedAt->toIso8601ZuluString() : true;
+
+        if($this->authInfo->loggedIn) {
+            if($perms->check(Perm::G_COMMENTS_EDIT_ANY))
+                $post['can_edit'] = true;
+            if($perms->check(Perm::G_COMMENTS_DELETE_ANY))
+                $post['can_delete'] = $post['can_delete_any'] = true;
+        }
+
+        if($replyInfos === null) {
+            $replies = $this->commentsCtx->posts->countPosts(parentInfo: $postInfo);
+            if($replies > 0)
+                $post['replies'] = $replies;
+        } else {
+            $replies = $this->convertPosts($perms, $catInfo, $replyInfos);
+            if(!empty($replies))
+                $post['replies'] = $replies;
+        }
+
+        return $post;
+    }
+
+    private static function error(HttpResponseBuilder $response, int $code, string $name, string $text, array $extra = []): array {
+        $response->statusCode = $code;
+
+        return [
+            'error' => array_merge($extra, [
+                'name' => $name,
+                'text' => $text,
+            ]),
+        ];
+    }
+
+    /** @return void|int|array{error: array{name: string, text: string}} */
+    #[HttpMiddleware('/comments')]
+    public function checkCsrf(HttpResponseBuilder $response, HttpRequest $request) {
+        if(in_array($request->method, ['DELETE', 'PATCH', 'POST'])) {
+            if(!$this->authInfo->loggedIn)
+                return self::error($response, 401, 'comments:auth', 'You must be logged in to use the comments system.');
+            if(!CSRF::validate($request->getHeaderLine('x-csrf-token')))
+                return self::error($response, 403, 'comments:csrf', 'Request could not be verified. Please try again.');
+            if($this->usersCtx->hasActiveBan($this->authInfo->userInfo))
+                return self::error($response, 403, 'comments:csrf', 'You are banned, check your profile for more information.');
+        }
+
+        $response->setHeader('X-CSRF-Token', CSRF::token());
+    }
+
+    #[HttpGet('/comments/categories/([A-Za-z0-9-]+)')]
+    public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): array {
+        try {
+            $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName);
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
+        }
+
+        $perms = $this->getGlobalPerms();
+        $result = [];
+        $category = [
+            'name' => $catInfo->name,
+            'created' => $catInfo->createdAt->toIso8601ZuluString(),
+        ];
+
+        if($catInfo->locked)
+            $category['locked'] = $catInfo->lockedAt->toIso8601ZuluString();
+
+        if($catInfo->ownerId !== null)
+            try {
+                $category['owner'] = $this->convertUser(
+                    $this->usersCtx->getUserInfo($catInfo->ownerId)
+                );
+            } catch(RuntimeException $ex) {}
+
+        $result['category'] = $category;
+
+        if($this->authInfo->loggedIn) {
+            $user = $this->convertUser($this->authInfo->userInfo, 100);
+
+            if($perms->check(Perm::G_COMMENTS_CREATE))
+                $user['can_create'] = true;
+            if($perms->check(Perm::G_COMMENTS_PIN) || $catInfo->ownerId === $this->authInfo->userId)
+                $user['can_pin'] = true;
+            if($perms->check(Perm::G_COMMENTS_VOTE))
+                $user['can_vote'] = true;
+            if($perms->check(Perm::G_COMMENTS_LOCK))
+                $user['can_lock'] = true;
+
+            $result['user'] = $user;
+        }
+
+        try {
+            $posts = $this->convertPosts($perms, $catInfo, $this->commentsCtx->posts->getPosts(
+                categoryInfo: $catInfo,
+                replies: false,
+            ), true);
+        } catch(RuntimeException $ex) {
+            $posts = [];
+        }
+
+        $result['posts'] = $posts;
+
+        return $result;
+    }
+
+    #[HttpPost('/comments/categories/([A-Za-z0-9-]+)')]
+    public function patchCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): array {
+        if(!($request->content instanceof FormHttpContent))
+            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
+
+        try {
+            $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName);
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
+        }
+
+        $perms = $this->getGlobalPerms();
+        $locked = null;
+
+        if($request->content->hasParam('lock')) {
+            if(!$perms->check(Perm::G_COMMENTS_LOCK))
+                return self::error($response, 403, 'comments:lock-not-allowed', 'You are not allowed to lock this comment section.');
+
+            $locked = !empty($request->content->getParam('lock'));
+        }
+
+        $this->commentsCtx->categories->updateCategory(
+            $catInfo,
+            locked: $locked,
+        );
+
+        try {
+            $catInfo = $this->commentsCtx->categories->getCategory(categoryId: $catInfo->id);
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
+        }
+
+        $result = ['name' => $catInfo->name];
+        if($locked !== null)
+            $result['locked'] = $catInfo->locked ? $catInfo->lockedAt->toIso8601ZuluString() : false;
+
+        return $result;
+    }
+
+    #[HttpPost('/comments/posts')]
+    public function postPost(HttpResponseBuilder $response, HttpRequest $request): array {
+        if(!($request->content instanceof FormHttpContent))
+            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
+
+        $perms = $this->getGlobalPerms();
+        if(!$perms->check(Perm::G_COMMENTS_CREATE))
+            return self::error($response, 403, 'comments:create-not-allowed', 'You are not allowed to post comments.');
+
+        if(!$request->content->hasParam('category') || !$request->content->hasParam('body'))
+            return self::error($response, 400, 'comments:missing-fields', 'Required fields are not specified.');
+
+        $pinned = false;
+        $body = preg_replace("/[\r\n]{2,}/", "\n", (string)$request->content->getParam('body'));
+        if(mb_strlen(mb_trim($body)) < 1)
+            return self::error($response, 400, 'comments:body-too-short', 'Your comment must be longer.');
+        if(mb_strlen($body) > 5000)
+            return self::error($response, 400, 'comments:body-too-long', 'Your comment is too long.');
+
+        try {
+            $catInfo = $this->commentsCtx->categories->getCategory(name: (string)$request->content->getParam('category'));
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
+        }
+
+        if($request->content->hasParam('reply_to')) {
+            try {
+                $replyToInfo = $this->commentsCtx->posts->getPost((string)$request->content->getParam('reply_to'));
+                if($replyToInfo->deleted)
+                    return self::error($response, 404, 'comments:parent-not-found', 'The comment you are trying to reply to does not exist.');
+            } catch(RuntimeException $ex) {
+                return self::error($response, 404, 'comments:parent-not-found', 'The comment you are trying to reply to does not exist.');
+            }
+        } else
+            $replyToInfo = null;
+
+        if($request->content->hasParam('pin')) {
+            if(!$perms->check(Perm::G_COMMENTS_PIN) && $catInfo->ownerId !== $this->authInfo->userId)
+                return self::error($response, 403, 'comments:pin-not-allowed', 'You are not allowed to pin comments.');
+            if($replyToInfo !== null)
+                return self::error($response, 400, 'comments:post-not-root', 'Replies cannot be pinned.');
+
+            $pinned = !empty($request->content->getParam('pin'));
+        }
+
+        try {
+            $postInfo = $this->commentsCtx->posts->createPost(
+                $catInfo,
+                $replyToInfo,
+                $this->authInfo->userInfo,
+                $body,
+                $pinned
+            );
+        } catch(RuntimeException $ex) {
+            return self::error($response, 500, 'comments:create-failed', 'Failed to create your comment. Please report this as a bug if it persists.');
+        }
+
+        $response->statusCode = 201;
+        return $this->convertPost($perms, $catInfo, $postInfo);
+    }
+
+    #[HttpGet('/comments/posts/([0-9]+)')]
+    public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $perms = $this->getGlobalPerms();
+        $post = $this->convertPost(
+            $perms,
+            $catInfo,
+            $postInfo,
+            $this->commentsCtx->posts->getPosts(parentInfo: $postInfo)
+        );
+        if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies']))
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+
+        return $post;
+    }
+
+    #[HttpGet('/comments/posts/([0-9]+)/replies')]
+    public function getPostReplies(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        return $this->convertPosts(
+            $this->getGlobalPerms(),
+            $catInfo,
+            $this->commentsCtx->posts->getPosts(parentInfo: $postInfo)
+        );
+    }
+
+    // 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): array {
+        if(!($request->content instanceof FormHttpContent))
+            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
+
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $perms = $this->getGlobalPerms();
+        if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && ($catInfo->locked || $postInfo->deleted))
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+
+        $body = null;
+        $pinned = null;
+        $edited = false;
+
+        if($request->content->hasParam('pin')) {
+            if(!$perms->check(Perm::G_COMMENTS_PIN) && $catInfo->ownerId !== $this->authInfo->userId)
+                return self::error($response, 403, 'comments:pin-not-allowed', 'You are not allowed to pin comments.');
+            if($postInfo->reply)
+                return self::error($response, 400, 'comments:post-not-root', 'Replies cannot be pinned.');
+
+            $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 self::error($response, 403, 'comments:edit-not-allowed', 'You are not allowed to edit comments.');
+
+            $body = preg_replace("/[\r\n]{2,}/", "\n", (string)$request->content->getParam('body'));
+            if(mb_strlen(mb_trim($body)) < 1)
+                return self::error($response, 400, 'comments:body-too-short', 'Your comment must be longer.');
+            if(mb_strlen($body) > 5000)
+                return self::error($response, 400, 'comments:body-too-long', 'Your comment is too long.');
+
+            $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 self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $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]+)')]
+    public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array|string {
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            if($postInfo->deleted)
+                return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+            if($catInfo->locked)
+                return self::error($response, 403, 'comments:category-locked-delete', 'The comment section this comment is in is locked, it cannot be deleted.');
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $perms = $this->getGlobalPerms();
+        if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && !(
+            ($postInfo->userId === $this->authInfo->userId || $catInfo->ownerId === $this->authInfo->userId)
+            && $perms->check(Perm::G_COMMENTS_DELETE_OWN)
+        )) return self::error($response, 403, 'comments:delete-not-allowed', 'You are not allowed to delete this comment.');
+
+        $this->commentsCtx->posts->deletePost($postInfo);
+
+        $response->statusCode = 204;
+        return '';
+    }
+
+    #[HttpPost('/comments/posts/([0-9]+)/restore')]
+    public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+        if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
+            return self::error($response, 403, 'comments:restore-not-allowed', 'You are not allowed to restore comments.');
+
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            if(!$postInfo->deleted)
+                return self::error($response, 400, 'comments:post-not-deleted', 'This comment is not currently deleted.');
+
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+            if($catInfo->locked)
+                return self::error($response, 403, 'comments:category-locked-restore', 'The comment section this comment is in is locked, it cannot be restored.');
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $this->commentsCtx->posts->restorePost($postInfo);
+
+        return [];
+    }
+
+    #[HttpPost('/comments/posts/([0-9]+)/nuke')]
+    public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+        if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
+            return self::error($response, 403, 'comments:nuke-not-allowed', 'You are not allowed to permanently delete comments.');
+
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            if(!$postInfo->deleted)
+                return self::error($response, 400, 'comments:post-not-deleted', 'This comment is not currently (soft-)deleted.');
+
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+            if($catInfo->locked)
+                return self::error($response, 403, 'comments:category-locked-nuke', 'The comment section this comment is in is locked, it cannot be permanently deleted.');
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $this->commentsCtx->posts->nukePost($postInfo);
+
+        return [];
+    }
+
+    #[HttpPost('/comments/posts/([0-9]+)/vote')]
+    public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
+        if(!($request->content instanceof FormHttpContent))
+            return self::error($response, 400, 'comments:content', 'Provided content could not be understood.');
+
+        $vote = (int)$request->content->getParam('vote', FILTER_SANITIZE_NUMBER_INT);
+        if($vote === 0)
+            return self::error($response, 400, 'comments:vote', 'Could not process vote.');
+
+        if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE))
+            return self::error($response, 403, 'comments:vote-not-allowed', 'You are not allowed to vote on comments.');
+
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            if($postInfo->deleted)
+                return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+            if($catInfo->locked)
+                return self::error($response, 403, 'comments:category-locked-vote', 'The comment section this comment is in is locked, you cannot vote on it.');
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $this->commentsCtx->votes->addVote(
+            $postInfo,
+            $this->authInfo->userInfo,
+            max(-1, min(1, $vote))
+        );
+
+        $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
+        $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
+
+        $response->statusCode = 201;
+        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): array {
+        if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE))
+            return self::error($response, 403, 'comments:vote-not-allowed', 'You are not allowed to vote on comments.');
+
+        try {
+            $postInfo = $this->commentsCtx->posts->getPost($commentId);
+            if($postInfo->deleted)
+                return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+
+            $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
+            if($catInfo->locked)
+                return self::error($response, 403, 'comments:category-locked-vote', 'The comment section this comment is in is locked, you cannot vote on it.');
+        } catch(RuntimeException $ex) {
+            return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
+        }
+
+        $this->commentsCtx->votes->removeVote(
+            $postInfo,
+            $this->authInfo->userInfo
+        );
+
+        $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
+        $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
+
+        return [
+            'vote' => $voteInfo->weight,
+            'positive' => $votes->positive,
+            'negative' => $votes->negative,
+        ];
+    }
+}
diff --git a/src/Comments/CommentsPostVoteInfo.php b/src/Comments/CommentsVoteInfo.php
similarity index 83%
rename from src/Comments/CommentsPostVoteInfo.php
rename to src/Comments/CommentsVoteInfo.php
index 246e563b..7001e6b6 100644
--- a/src/Comments/CommentsPostVoteInfo.php
+++ b/src/Comments/CommentsVoteInfo.php
@@ -3,15 +3,15 @@ namespace Misuzu\Comments;
 
 use Index\Db\DbResult;
 
-class CommentsPostVoteInfo {
+class CommentsVoteInfo {
     public function __construct(
         public private(set) string $commentId,
         public private(set) string $userId,
         public private(set) int $weight
     ) {}
 
-    public static function fromResult(DbResult $result): CommentsPostVoteInfo {
-        return new CommentsPostVoteInfo(
+    public static function fromResult(DbResult $result): CommentsVoteInfo {
+        return new CommentsVoteInfo(
             commentId: $result->getString(0),
             userId: $result->getString(1),
             weight: $result->getInteger(2),
diff --git a/src/Comments/CommentsVotesAggregate.php b/src/Comments/CommentsVotesAggregate.php
new file mode 100644
index 00000000..72edbf19
--- /dev/null
+++ b/src/Comments/CommentsVotesAggregate.php
@@ -0,0 +1,20 @@
+<?php
+namespace Misuzu\Comments;
+
+use Index\Db\DbResult;
+
+class CommentsVotesAggregate {
+    public function __construct(
+        public private(set) string $commentId,
+        public private(set) int $positive,
+        public private(set) int $negative,
+    ) {}
+
+    public static function fromResult(DbResult $result): CommentsVotesAggregate {
+        return new CommentsVotesAggregate(
+            commentId: $result->getString(0),
+            positive: $result->getInteger(1),
+            negative: $result->getInteger(2),
+        );
+    }
+}
diff --git a/src/Comments/CommentsVotesData.php b/src/Comments/CommentsVotesData.php
new file mode 100644
index 00000000..41e79af6
--- /dev/null
+++ b/src/Comments/CommentsVotesData.php
@@ -0,0 +1,87 @@
+<?php
+namespace Misuzu\Comments;
+
+use RuntimeException;
+use Index\Db\{DbConnection,DbStatementCache};
+use Misuzu\Users\UserInfo;
+
+class CommentsVotesData {
+    private DbStatementCache $cache;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function getVotesAggregate(
+        CommentsPostInfo|string $postInfo
+    ): CommentsVotesAggregate {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT ? AS id,
+            (SELECT SUM(comment_vote) FROM msz_comments_votes WHERE comment_id = id AND comment_vote > 0),
+            (SELECT SUM(comment_vote) FROM msz_comments_votes WHERE comment_id = id AND comment_vote < 0)
+        SQL);
+        $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('failed to aggregate comment votes');
+
+        return CommentsVotesAggregate::fromResult($result);
+    }
+
+    public function getVote(
+        CommentsPostInfo|string $postInfo,
+        UserInfo|string $userInfo
+    ): CommentsVoteInfo {
+        // SUM() here makes it so a result row is always returned, albeit with just NULLs
+        $stmt = $this->cache->get(<<<SQL
+            SELECT comment_id, user_id, SUM(comment_vote)
+            FROM msz_comments_votes
+            WHERE comment_id = ?
+            AND user_id = ?
+        SQL);
+        $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Failed to fetch vote info.');
+
+        return CommentsVoteInfo::fromResult($result);
+    }
+
+    public function addVote(
+        CommentsPostInfo|string $postInfo,
+        UserInfo|string $userInfo,
+        int $weight
+    ): void {
+        if($weight === 0)
+            return;
+
+        $stmt = $this->cache->get(<<<SQL
+            REPLACE INTO msz_comments_votes (
+                comment_id, user_id, comment_vote
+            ) VALUES (?, ?, ?)
+        SQL);
+        $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->nextParameter($weight);
+        $stmt->execute();
+    }
+
+    public function removeVote(
+        CommentsPostInfo|string $postInfo,
+        UserInfo|string $userInfo
+    ): void {
+        $stmt = $this->cache->get(<<<SQL
+            DELETE FROM msz_comments_votes
+            WHERE comment_id = ?
+            AND user_id = ?
+        SQL);
+        $stmt->nextParameter($postInfo instanceof CommentsPostInfo ? $postInfo->id : $postInfo);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->execute();
+    }
+}
diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php
index 8ba5d677..9b9cdbf8 100644
--- a/src/Home/HomeRoutes.php
+++ b/src/Home/HomeRoutes.php
@@ -12,7 +12,7 @@ use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Changelog\ChangelogData;
-use Misuzu\Comments\CommentsData;
+use Misuzu\Comments\CommentsContext;
 use Misuzu\Counters\CountersData;
 use Misuzu\News\{NewsData,NewsCategoryInfo,NewsPostInfo};
 use Misuzu\Users\{UsersContext,UserInfo};
@@ -23,13 +23,13 @@ class HomeRoutes implements RouteHandler, UrlSource {
     public function __construct(
         private Config $config,
         private DbConnection $dbConn,
-        private SiteInfo $siteInfo,
-        private AuthInfo $authInfo,
+        private UsersContext $usersCtx,
+        private CommentsContext $commentsCtx,
         private ChangelogData $changelog,
-        private CommentsData $comments,
         private CountersData $counters,
         private NewsData $news,
-        private UsersContext $usersCtx
+        private SiteInfo $siteInfo,
+        private AuthInfo $authInfo,
     ) {}
 
     /**
@@ -94,8 +94,10 @@ class HomeRoutes implements RouteHandler, UrlSource {
             else
                 $this->newsCategoryInfos[$categoryId] = $categoryInfo = $this->news->getCategory(postInfo: $postInfo);
 
-            $commentsCount = $postInfo->commentsSectionId
-                ? $this->comments->countPosts(categoryInfo: $postInfo->commentsSectionId, deleted: false) : 0;
+            $commentsCount = $this->commentsCtx->posts->countPosts(
+                categoryName: $postInfo->commentsCategoryName,
+                deleted: false,
+            );
 
             $posts[] = [
                 'post' => $postInfo,
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index ed8203c9..270a8d5c 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -27,11 +27,11 @@ class MisuzuContext {
     public private(set) Emoticons\EmotesData $emotes;
     public private(set) Changelog\ChangelogData $changelog;
     public private(set) News\NewsData $news;
-    public private(set) Comments\CommentsData $comments;
 
     public private(set) DatabaseContext $dbCtx;
     public private(set) Apps\AppsContext $appsCtx;
     public private(set) Auth\AuthContext $authCtx;
+    public private(set) Comments\CommentsContext $commentsCtx;
     public private(set) Forum\ForumContext $forumCtx;
     public private(set) Messages\MessagesContext $messagesCtx;
     public private(set) OAuth2\OAuth2Context $oauth2Ctx;
@@ -68,6 +68,7 @@ class MisuzuContext {
 
         $this->deps->register($this->appsCtx = $this->deps->constructLazy(Apps\AppsContext::class));
         $this->deps->register($this->authCtx = $this->deps->constructLazy(Auth\AuthContext::class, config: $this->config->scopeTo('auth')));
+        $this->deps->register($this->commentsCtx = $this->deps->constructLazy(Comments\CommentsContext::class));
         $this->deps->register($this->forumCtx = $this->deps->constructLazy(Forum\ForumContext::class));
         $this->deps->register($this->messagesCtx = $this->deps->constructLazy(Messages\MessagesContext::class));
         $this->deps->register($this->oauth2Ctx = $this->deps->constructLazy(OAuth2\OAuth2Context::class, config: $this->config->scopeTo('oauth2')));
@@ -77,7 +78,6 @@ class MisuzuContext {
 
         $this->deps->register($this->auditLog = $this->deps->constructLazy(AuditLog\AuditLogData::class));
         $this->deps->register($this->changelog = $this->deps->constructLazy(Changelog\ChangelogData::class));
-        $this->deps->register($this->comments = $this->deps->constructLazy(Comments\CommentsData::class));
         $this->deps->register($this->counters = $this->deps->constructLazy(Counters\CountersData::class));
         $this->deps->register($this->emotes = $this->deps->constructLazy(Emoticons\EmotesData::class));
         $this->deps->register($this->news = $this->deps->constructLazy(News\NewsData::class));
@@ -173,6 +173,8 @@ class MisuzuContext {
         $routingCtx->register($this->deps->constructLazy(Users\Assets\AssetsRoutes::class));
         $routingCtx->register($this->deps->constructLazy(Info\InfoRoutes::class));
         $routingCtx->register($this->deps->constructLazy(News\NewsRoutes::class));
+
+        $routingCtx->register($this->deps->constructLazy(Comments\CommentsRoutes::class));
         $routingCtx->register($this->deps->constructLazy(
             Messages\MessagesRoutes::class,
             config: $this->config->scopeTo('messages')
diff --git a/src/News/NewsData.php b/src/News/NewsData.php
index 4294c7ab..331ead24 100644
--- a/src/News/NewsData.php
+++ b/src/News/NewsData.php
@@ -220,7 +220,12 @@ class NewsData {
         $hasPagination = $pagination !== null;
 
         $args = 0;
-        $query = 'SELECT post_id, category_id, user_id, comment_section_id, post_featured, post_title, post_text, UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created), UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted) FROM msz_news_posts';
+        $query = <<<SQL
+            SELECT post_id, category_id, user_id, post_featured, post_title, post_text,
+                UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created),
+                UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted)
+            FROM msz_news_posts
+        SQL;
         if($hasCategoryInfo) {
             ++$args;
             $query .= ' WHERE category_id = ?';
@@ -259,7 +264,13 @@ class NewsData {
     }
 
     public function getPost(string $postId): NewsPostInfo {
-        $stmt = $this->cache->get('SELECT post_id, category_id, user_id, comment_section_id, post_featured, post_title, post_text, UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created), UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted) FROM msz_news_posts WHERE post_id = ?');
+        $stmt = $this->cache->get(<<<SQL
+            SELECT post_id, category_id, user_id, post_featured, post_title, post_text,
+                UNIX_TIMESTAMP(post_scheduled), UNIX_TIMESTAMP(post_created),
+                UNIX_TIMESTAMP(post_updated), UNIX_TIMESTAMP(post_deleted)
+            FROM msz_news_posts
+            WHERE post_id = ?
+        SQL);
         $stmt->nextParameter($postId);
         $stmt->execute();
 
@@ -378,19 +389,4 @@ class NewsData {
         $stmt->nextParameter($postInfo);
         $stmt->execute();
     }
-
-    public function updatePostCommentCategory(
-        NewsPostInfo|string $postInfo,
-        CommentsCategoryInfo|string $commentsCategory
-    ): void {
-        if($postInfo instanceof NewsPostInfo)
-            $postInfo = $postInfo->id;
-        if($commentsCategory instanceof CommentsCategoryInfo)
-            $commentsCategory = $commentsCategory->id;
-
-        $stmt = $this->cache->get('UPDATE msz_news_posts SET comment_section_id = ? WHERE post_id = ?');
-        $stmt->nextParameter($commentsCategory);
-        $stmt->nextParameter($postInfo);
-        $stmt->execute();
-    }
 }
diff --git a/src/News/NewsPostInfo.php b/src/News/NewsPostInfo.php
index e01a8bcb..3534fe8c 100644
--- a/src/News/NewsPostInfo.php
+++ b/src/News/NewsPostInfo.php
@@ -9,7 +9,6 @@ class NewsPostInfo {
         public private(set) string $id,
         public private(set) string $categoryId,
         public private(set) ?string $userId,
-        public private(set) ?string $commentsSectionId,
         public private(set) bool $featured,
         public private(set) string $title,
         public private(set) string $body,
@@ -24,14 +23,13 @@ class NewsPostInfo {
             id: $result->getString(0),
             categoryId: $result->getString(1),
             userId: $result->getStringOrNull(2),
-            commentsSectionId: $result->getStringOrNull(3),
-            featured: $result->getBoolean(4),
-            title: $result->getString(5),
-            body: $result->getString(6),
-            scheduledTime: $result->getInteger(7),
-            createdTime: $result->getInteger(8),
-            updatedTime: $result->getInteger(9),
-            deletedTime: $result->getIntegerOrNull(10),
+            featured: $result->getBoolean(3),
+            title: $result->getString(4),
+            body: $result->getString(5),
+            scheduledTime: $result->getInteger(6),
+            createdTime: $result->getInteger(7),
+            updatedTime: $result->getInteger(8),
+            deletedTime: $result->getIntegerOrNull(9),
         );
     }
 
diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php
index 5a32c9a9..601439ef 100644
--- a/src/News/NewsRoutes.php
+++ b/src/News/NewsRoutes.php
@@ -9,7 +9,7 @@ use Index\Syndication\FeedBuilder;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
 use Misuzu\Auth\AuthInfo;
-use Misuzu\Comments\{CommentsData,CommentsCategory,CommentsEx};
+use Misuzu\Comments\CommentsContext;
 use Misuzu\Parsers\{Parsers,TextFormat};
 use Misuzu\Users\{UsersContext,UserInfo};
 
@@ -20,9 +20,9 @@ class NewsRoutes implements RouteHandler, UrlSource {
         private SiteInfo $siteInfo,
         private AuthInfo $authInfo,
         private UrlRegistry $urls,
-        private NewsData $news,
         private UsersContext $usersCtx,
-        private CommentsData $comments
+        private CommentsContext $commentsCtx,
+        private NewsData $news,
     ) {}
 
     /** @var array<string, NewsCategoryInfo> */
@@ -54,9 +54,10 @@ class NewsRoutes implements RouteHandler, UrlSource {
             else
                 $this->categoryInfos[$categoryId] = $categoryInfo = $this->news->getCategory(postInfo: $postInfo);
 
-            $commentsCount = $postInfo->commentsSectionId
-                ? $this->comments->countPosts(categoryInfo: $postInfo->commentsSectionId, deleted: false)
-                : 0;
+            $commentsCount = $this->commentsCtx->posts->countPosts(
+                categoryName: $postInfo->commentsCategoryName,
+                deleted: false,
+            );
 
             $posts[] = [
                 'post' => $postInfo,
@@ -156,26 +157,16 @@ class NewsRoutes implements RouteHandler, UrlSource {
             return 404;
 
         $categoryInfo = $this->news->getCategory(postInfo: $postInfo);
-
-        if($postInfo->commentsSectionId !== null)
-            try {
-                $commentsCategory = $this->comments->getCategory(categoryId: $postInfo->commentsSectionId);
-            } catch(RuntimeException $ex) {}
-
-        if(!isset($commentsCategory)) {
-            $commentsCategory = $this->comments->ensureCategory($postInfo->commentsCategoryName);
-            $this->news->updatePostCommentCategory($postInfo, $commentsCategory);
-        }
-
         $userInfo = $postInfo->userId !== null ? $this->usersCtx->getUserInfo($postInfo->userId) : null;
-        $comments = new CommentsEx($this->authInfo, $this->comments, $this->usersCtx);
+
+        // should this be run here?
+        $this->commentsCtx->categories->ensureCategoryExists($postInfo->commentsCategoryName);
 
         return Template::renderRaw('news.post', [
             'post_info' => $postInfo,
             'post_category_info' => $categoryInfo,
             'post_user_info' => $userInfo,
             'post_user_colour' => $this->usersCtx->getUserColour($userInfo),
-            'comments_info' => $comments->getCommentsForLayout($commentsCategory),
         ]);
     }
 
diff --git a/src/Perm.php b/src/Perm.php
index 574eb03c..cc5e65d8 100644
--- a/src/Perm.php
+++ b/src/Perm.php
@@ -39,8 +39,8 @@ final class Perm {
     public const G_MESSAGES_SEND             = 0b00000_00000000_00000000_00100000_00000000_00000000_00000000;
 
     public const G_COMMENTS_CREATE           = 0b00000_00000000_00000001_00000000_00000000_00000000_00000000;
-    public const G_COMMENTS_EDIT_OWN         = 0b00000_00000000_00000010_00000000_00000000_00000000_00000000; // unused: editing not implemented
-    public const G_COMMENTS_EDIT_ANY         = 0b00000_00000000_00000100_00000000_00000000_00000000_00000000; // unused: editing not implemented
+    public const G_COMMENTS_EDIT_OWN         = 0b00000_00000000_00000010_00000000_00000000_00000000_00000000;
+    public const G_COMMENTS_EDIT_ANY         = 0b00000_00000000_00000100_00000000_00000000_00000000_00000000;
     public const G_COMMENTS_DELETE_OWN       = 0b00000_00000000_00001000_00000000_00000000_00000000_00000000;
     public const G_COMMENTS_DELETE_ANY       = 0b00000_00000000_00010000_00000000_00000000_00000000_00000000;
     public const G_COMMENTS_PIN              = 0b00000_00000000_00100000_00000000_00000000_00000000_00000000;
diff --git a/src/Routing/RoutingErrorHandler.php b/src/Routing/RoutingErrorHandler.php
index 87fff7a7..167d5c22 100644
--- a/src/Routing/RoutingErrorHandler.php
+++ b/src/Routing/RoutingErrorHandler.php
@@ -12,10 +12,10 @@ class RoutingErrorHandler extends HtmlHttpErrorHandler {
             return;
         }
 
-        $path = sprintf('/error-%03d.html', $code);
-        if(is_file(Misuzu::PATH_PUBLIC . $path)) {
+        $path = sprintf('%s/error-%03d.html', Misuzu::PATH_PUBLIC, $code);
+        if(is_file($path)) {
             $response->setTypeHTML();
-            $response->accelRedirect($path);
+            $response->content = file_get_contents($path);
             return;
         }
 
diff --git a/templates/_layout/comments.twig b/templates/_layout/comments.twig
deleted file mode 100644
index b817423a..00000000
--- a/templates/_layout/comments.twig
+++ /dev/null
@@ -1,215 +0,0 @@
-{% macro comments_input(category, user, perms, reply_to, return_url) %}
-    {% set reply_mode = reply_to is not null %}
-
-    {% from 'macros.twig' import avatar %}
-    {% from '_layout/input.twig' import input_hidden, input_csrf, input_checkbox %}
-
-    <form class="comment comment--input{% if reply_mode %} comment--reply{% endif %}"
-        method="post" action="{{ url('comment-create',  {'return': return_url}) }}"
-        id="comment-{{ reply_mode ? 'reply-' ~ reply_to.id : 'create-' ~ category.id }}">
-        {{ input_hidden('comment[category]', category.id) }}
-        {{ input_csrf() }}
-
-        {% if reply_mode %}
-            {{ input_hidden('comment[reply]', reply_to.id) }}
-        {% endif %}
-
-        <div class="comment__container">
-            <div class="avatar comment__avatar">
-                {{ avatar(user.id, reply_mode ? 40 : 50, user.name) }}
-            </div>
-            <div class="comment__content">
-                <textarea
-                    class="comment__text input__textarea comment__text--input"
-                    name="comment[text]" placeholder="Share your extensive insights..."></textarea>
-                <div class="comment__actions">
-                    {% if not reply_mode %}
-                        {% if perms.can_pin|default(false) %}
-                            {{ input_checkbox('comment[pin]', 'Pin this comment', false, 'comment__action') }}
-                        {% endif %}
-                        {% if perms.can_lock|default(false) %}
-                            {{ input_checkbox('comment[lock]', 'Toggle locked status', false, 'comment__action') }}
-                        {% endif %}
-                    {% endif %}
-                    <button class="input__button comment__action comment__action--button comment__action--post">
-                        {{ reply_mode ? 'Reply' : 'Post' }}
-                    </button>
-                </div>
-            </div>
-        </div>
-    </form>
-{% endmacro %}
-
-{% macro comments_entry(comment, indent, category, user, colour, perms, return_url) %}
-    {% from 'macros.twig' import avatar %}
-    {% from '_layout/input.twig' import input_checkbox_raw %}
-
-    {% set replies = comment.replies %}
-    {% set poster = comment.user|default(null) %}
-    {% if comment.post is defined %}
-        {% set userVote = comment.vote.weight %}
-        {% set commenterColour = comment.colour %}
-        {% set comment = comment.post %}
-        {% set body = comment.body %}
-        {% set likes = comment.votesPositive %}
-        {% set dislikes = comment.votesNegative %}
-        {% set isReply = comment.isReply %}
-    {% else %}
-        {% set body = comment.text %}
-        {% set commenterColour = null %}
-        {% set userVote = comment.userVote %}
-        {% set likes = comment.likes %}
-        {% set dislikes = comment.dislikes %}
-        {% set isReply = comment.hasParent %}
-    {% endif %}
-
-    {% set hide_details = poster is null or comment.deleted and not perms.can_delete_any|default(false) %}
-
-    {% if perms.can_delete_any|default(false) or (not comment.deleted or replies|length > 0) %}
-        <div class="comment{% if comment.deleted %} comment--deleted{% endif %}" id="comment-{{ comment.id }}">
-            <div class="comment__container">
-                {% if hide_details %}
-                    <div class="comment__avatar">
-                        {{ avatar(0, indent > 1 ? 40 : 50) }}
-                    </div>
-                {% else %}
-                    <a class="comment__avatar" href="{{ url('user-profile', {'user': poster.id}) }}">
-                        {{ avatar(poster.id, indent > 1 ? 40 : 50, poster.name) }}
-                    </a>
-                {% endif %}
-                <div class="comment__content">
-                    <div class="comment__info">
-                        {% if not hide_details %}
-                            <a class="comment__user comment__user--link"
-                                href="{{ url('user-profile', {'user': poster.id}) }}"
-                                style="--user-colour: {{ commenterColour }}">{{ poster.name }}</a>
-                        {% endif %}
-                        <a class="comment__link" href="#comment-{{ comment.id }}">
-                            <time class="comment__date"
-                                title="{{ comment.createdTime|date('r') }}"
-                                datetime="{{ comment.createdTime|date('c') }}">
-                                {{ comment.createdTime|time_format }}
-                            </time>
-                        </a>
-                        {% if comment.pinned %}
-                            <span class="comment__pin">{% apply spaceless %}
-                                Pinned
-                                {% if comment.pinnedTime != comment.createdTime %}
-                                    <time title="{{ comment.pinnedTime|date('r') }}"
-                                        datetime="{{ comment.pinnedTime|date('c') }}">
-                                        {{ comment.pinnedTime|time_format }}
-                                    </time>
-                                {% endif %}
-                            {% endapply %}</span>
-                        {% endif %}
-                    </div>
-                    <div class="comment__text">
-                        {{ hide_details ? '(deleted)' : body }}
-                    </div>
-                    <div class="comment__actions">
-                        {% if not comment.deleted and user is not null %}
-                            {% if perms.can_vote|default(false) %}
-                                {% set like_vote_state = (userVote > 0 ? 0 : 1) %}
-                                {% set dislike_vote_state = (userVote < 0 ? 0 : -1) %}
-
-                                <a class="comment__action comment__action--link comment__action--vote comment__action--like{% if userVote > 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ like_vote_state }}"
-                                href="{{ url('comment-vote', { comment: comment.id, vote: like_vote_state, return: return_url, csrf: csrf_token() }) }}">
-                                    Like
-                                    {% if likes > 0 %}
-                                        ({{ likes|number_format }})
-                                    {% endif %}
-                                </a>
-                                <a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if userVote < 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ dislike_vote_state }}"
-                                href="{{ url('comment-vote', { comment: comment.id, vote: dislike_vote_state, return: return_url, csrf: csrf_token() }) }}">
-                                    Dislike
-                                    {% if dislikes > 0 %}
-                                        ({{ dislikes|number_format }})
-                                    {% endif %}
-                                </a>
-                            {% endif %}
-                            {% if perms.can_post|default(false) %}
-                                <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.id }}">Reply</label>
-                            {% endif %}
-                            {% if perms.can_delete_any|default(false) or (poster.id|default(0) == user.id and perms.can_delete|default(false)) %}
-                                <a class="comment__action comment__action--link comment__action--hide comment__action--delete" data-comment-id="{{ comment.id }}" href="{{ url('comment-delete', { comment: comment.id, return: return_url, csrf: csrf_token() }) }}">Delete</a>
-                            {% endif %}
-                            {# if user is not null %}
-                                <a class="comment__action comment__action--link comment__action--hide" href="#">Report</a>
-                            {% endif #}
-                            {% if not isReply and perms.can_pin|default(false) %}
-                                <a class="comment__action comment__action--link comment__action--hide comment__action--pin" data-comment-id="{{ comment.id }}" data-comment-pinned="{{ comment.pinned ? '1' : '0' }}" href="{{ url((comment.pinned ? 'comment-unpin' : 'comment-pin'), { comment: comment.id, return: return_url, csrf: csrf_token() }) }}">{{ comment.pinned ? 'Unpin' : 'Pin' }}</a>
-                            {% endif %}
-                        {% elseif perms.can_delete_any|default(false) %}
-                            <a class="comment__action comment__action--link comment__action--restore" data-comment-id="{{ comment.id }}" href="{{ url('comment-restore', { comment: comment.id, return: return_url, csrf: csrf_token() }) }}">Restore</a>
-                        {% endif %}
-                    </div>
-                </div>
-            </div>
-
-            <div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ comment.id }}-replies">
-                {% from _self import comments_entry, comments_input %}
-                {% if user|default(null) is not null and category|default(null) is not null and perms.can_post|default(false) %}
-                    {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.id}) }}
-                    {{ comments_input(category, user, perms, comment, return_url) }}
-                {% endif %}
-                {% if replies|length > 0 %}
-                    {% for reply in replies %}
-                        {{ comments_entry(reply, indent + 1, category, user, colour, perms, return_url) }}
-                    {% endfor %}
-                {% endif %}
-            </div>
-        </div>
-    {% endif %}
-{% endmacro %}
-
-{% macro comments_section(category, return_url) %}
-    {% set user = category.user %}
-    {% set colour = category.colour %}
-    {% set posts = category.posts %}
-    {% set perms = category.perms %}
-    {% set category = category.category %}
-
-    <div class="comments" id="comments">
-        <div class="comments__input">
-            {% if user|default(null) is null %}
-                <div class="comments__notice">
-                    Please <a href="{{ url('auth-login') }}" class="comments__notice__link">login</a> to comment.
-                </div>
-            {% elseif category|default(null) is null %}
-                <div class="comments__notice">
-                    Posting new comments here is disabled.
-                </div>
-            {% elseif not perms.can_lock|default(false) and category.locked %}
-                <div class="comments__notice">
-                    This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_format }}</time>.
-                </div>
-            {% elseif not perms.can_post|default(false) %}
-                <div class="comments__notice">
-                    You are not allowed to post comments.
-                </div>
-            {% else %}
-                {% from _self import comments_input %}
-                {{ comments_input(category, user, perms, null, return_url) }}
-            {% endif %}
-        </div>
-
-        {% if perms.can_lock|default(false) and category.locked %}
-            <div class="comments__notice comments__notice--staff">
-                This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_format }}</time>.
-            </div>
-        {% endif %}
-
-        <div class="comments__listing">
-            {% if posts|length > 0 %}
-                {% from _self import comments_entry %}
-                {% for comment in posts %}
-                    {{ comments_entry(comment, 1, category, user, colour, perms, return_url) }}
-                {% endfor %}
-            {% else %}
-                <div class="comments__none" id="_no_comments_notice_{{ category.id }}">
-                    There are no comments yet.
-                </div>
-            {% endif %}
-        </div>
-    </div>
-{% endmacro %}
diff --git a/templates/changelog/change.twig b/templates/changelog/change.twig
index cecb46e3..889243a6 100644
--- a/templates/changelog/change.twig
+++ b/templates/changelog/change.twig
@@ -1,6 +1,5 @@
 {% extends 'changelog/master.twig' %}
 {% from 'macros.twig' import container_title, avatar %}
-{% from '_layout/comments.twig' import comments_section %}
 
 {% set title = 'Changelog ยป Change #' ~ change_info.id %}
 {% set canonical_url = url('changelog-change', {'change': change_info.id}) %}
@@ -69,6 +68,6 @@
 
     <div class="container">
         {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments for ' ~ change_info.date) }}
-        {{ comments_section(comments_info, canonical_url) }}
+        <div class="js-comments" data-category="{{ change_info.commentsCategoryName }}"></div>
     </div>
 {% endblock %}
diff --git a/templates/changelog/index.twig b/templates/changelog/index.twig
index f1b327d8..48ea3762 100644
--- a/templates/changelog/index.twig
+++ b/templates/changelog/index.twig
@@ -1,7 +1,6 @@
 {% extends 'changelog/master.twig' %}
 {% from 'macros.twig' import pagination, container_title %}
 {% from 'changelog/macros.twig' import changelog_listing %}
-{% from '_layout/comments.twig' import comments_section %}
 
 {% set is_date = changelog_date > 0 %}
 {% set is_user = changelog_user is not null %}
@@ -50,10 +49,10 @@
         {% endif %}
     </div>
 
-    {% if is_date %}
+    {% if comments_category_name is defined and comments_category_name is not null %}
         <div class="container">
             {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }}
-            {{ comments_section(comments_info, canonical_url) }}
+            <div class="js-comments" data-category="{{ comments_category_name }}"></div>
         </div>
     {% endif %}
 {% endblock %}
diff --git a/templates/manage/news/posts.twig b/templates/manage/news/posts.twig
index acf8ed4e..0080f95e 100644
--- a/templates/manage/news/posts.twig
+++ b/templates/manage/news/posts.twig
@@ -14,7 +14,6 @@
                 {{ post.title }} |
                 {{ post.featured ? 'Featured' : 'Normal' }} |
                 User #{{ post.userId }} |
-                {% if post.commentsSectionId is not null %}Comments category #{{ post.commentsSectionId }}{% else %}No comments category{% endif %} |
                 Created {{ post.createdAt }} |
                 {{ post.published ? 'published' : 'Published ' ~ post.scheduledAt }} |
                 {{ post.edited ? 'Edited ' ~ post.updatedAt : 'not edited' }} |
diff --git a/templates/news/post.twig b/templates/news/post.twig
index 48734b30..30ef9249 100644
--- a/templates/news/post.twig
+++ b/templates/news/post.twig
@@ -1,6 +1,5 @@
 {% extends 'news/master.twig' %}
 {% from 'macros.twig' import container_title %}
-{% from '_layout/comments.twig' import comments_section %}
 {% from 'news/macros.twig' import news_post %}
 
 {% set title = post_info.title ~ ' :: News' %}
@@ -10,10 +9,8 @@
 {% block content %}
     {{ news_post(post_info, post_category_info, post_user_info, post_user_colour) }}
 
-    {% if comments_info is defined %}
-        <div class="container">
-            {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }}
-            {{ comments_section(comments_info, canonical_url) }}
-        </div>
-    {% endif %}
+    <div class="container">
+        {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }}
+        <div class="js-comments" data-category="{{ post_info.commentsCategoryName }}"></div>
+    </div>
 {% endblock %}