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">—</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 %}