diff --git a/assets/common.js/html.js b/assets/common.js/html.js index 4b7d3e4a..267d153e 100644 --- a/assets/common.js/html.js +++ b/assets/common.js/html.js @@ -13,6 +13,7 @@ const $removeChildren = function(element) { }; const $jsx = (type, props, ...children) => $create({ tag: type, attrs: props, child: children }); +const $jsxf = window.DocumentFragment; const $create = function(info, attrs, child, created) { info = info || {}; @@ -27,74 +28,80 @@ const $create = function(info, attrs, child, created) { info.created = created; } - const elem = document.createElement(info.tag || 'div'); + let elem; - if(info.attrs) { - const attrs = info.attrs; + if(typeof info.tag === 'function') { + elem = new info.tag(info.attrs || {}); + } else { + elem = document.createElement(info.tag || 'div'); - for(let key in attrs) { - const attr = attrs[key]; - if(attr === undefined || attr === null) - continue; + if(info.attrs) { + const attrs = info.attrs; - switch(typeof attr) { - case 'function': - if(key.substring(0, 2) === 'on') - key = key.substring(2).toLowerCase(); - elem.addEventListener(key, attr); - break; + for(let key in attrs) { + const attr = attrs[key]; + if(attr === undefined || attr === null) + continue; - case 'object': - if(attr instanceof Array) { - if(key === 'class') - key = 'classList'; + switch(typeof attr) { + case 'function': + if(key.substring(0, 2) === 'on') + key = key.substring(2).toLowerCase(); + elem.addEventListener(key, attr); + break; - const prop = elem[key]; - let addFunc = null; + case 'object': + if(attr instanceof Array) { + if(key === 'class') + key = 'classList'; - if(prop instanceof Array) - addFunc = prop.push.bind(prop); - else if(prop instanceof DOMTokenList) - addFunc = prop.add.bind(prop); + const prop = elem[key]; + let addFunc = null; - if(addFunc !== null) { - for(let j = 0; j < attr.length; ++j) - addFunc(attr[j]); + if(prop instanceof Array) + addFunc = prop.push.bind(prop); + else if(prop instanceof DOMTokenList) + addFunc = prop.add.bind(prop); + + if(addFunc !== null) { + for(let j = 0; j < attr.length; ++j) + addFunc(attr[j]); + } else { + if(key === 'classList') + key = 'class'; + elem.setAttribute(key, attr.toString()); + } } else { - if(key === 'classList') - key = 'class'; - elem.setAttribute(key, attr.toString()); + if(key === 'class' || key === 'className') + key = 'classList'; + + let setFunc = null; + if(elem[key] instanceof DOMTokenList) + setFunc = (ak, av) => { if(av) elem[key].add(ak); }; + else if(elem[key] instanceof CSSStyleDeclaration) + setFunc = (ak, av) => { elem[key].setProperty(ak, av); } + else + setFunc = (ak, av) => { elem[key][ak] = av; }; + + for(const attrKey in attr) { + const attrValue = attr[attrKey]; + if(attrValue) + setFunc(attrKey, attrValue); + } } - } else { - if(key === 'class' || key === 'className') - key = 'classList'; + break; - let setFunc = null; - if(elem[key] instanceof DOMTokenList) - setFunc = (ak, av) => { if(av) elem[key].add(ak); }; - else if(elem[key] instanceof CSSStyleDeclaration) - setFunc = (ak, av) => { elem[key].setProperty(ak, av); } - else - setFunc = (ak, av) => { elem[key][ak] = av; }; + case 'boolean': + if(attr) + elem.setAttribute(key, ''); + break; - for(const attrKey in attr) { - const attrValue = attr[attrKey]; - if(attrValue) - setFunc(attrKey, attrValue); - } - } - break; - - case 'boolean': - if(attr) - elem.setAttribute(key, ''); - break; - - default: - if(key === 'className') - key = 'class'; - elem.setAttribute(key, attr.toString()); - break; + default: + if(key === 'className') + key = 'class'; + elem.setAttribute(key, attr.toString()); + break; + } } } } @@ -118,17 +125,17 @@ const $create = function(info, attrs, child, created) { if(child === null) break; - if(child instanceof Element) { + if(child instanceof Node) { elem.appendChild(child); } else if('element' in child) { const childElem = child.element; - if(childElem instanceof Element) + if(childElem instanceof Node) elem.appendChild(childElem); else elem.appendChild($create(child)); } else if('getElement' in child) { const childElem = child.getElement(); - if(childElem instanceof Element) + if(childElem instanceof Node) elem.appendChild(childElem); else elem.appendChild($create(child)); diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css index b75d287b..f3b6d8c6 100644 --- a/assets/misuzu.css/comments/entry.css +++ b/assets/misuzu.css/comments/entry.css @@ -101,12 +101,13 @@ gap: 6px; padding: 3px 6px; cursor: pointer; - transition: background-color .2s; + transition: background-color .1s; min-width: 24px; min-height: 22px; + color: inherit; } -.comments-entry-action:hover, -.comments-entry-action:focus { +.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 { diff --git a/assets/misuzu.css/comments/main.css b/assets/misuzu.css/comments/main.css index 26dc9a1e..42270309 100644 --- a/assets/misuzu.css/comments/main.css +++ b/assets/misuzu.css/comments/main.css @@ -1,3 +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.js/comments/api.js b/assets/misuzu.js/comments/api.js index 107b6200..0c99ce8d 100644 --- a/assets/misuzu.js/comments/api.js +++ b/assets/misuzu.js/comments/api.js @@ -25,13 +25,25 @@ const MszCommentsApi = (() => { if(typeof args !== 'object' || args === null) throw 'args must be a non-null object'; - const { status } = await $xhr.patch( + const { status, body } = await $xhr.post( `/comments/categories/${name}`, - { csrf: true }, + { csrf: true, type: 'json' }, args ); + if(status === 400) + throw 'your update is not acceptable'; + if(status === 401) + throw 'you must be logged in to do that'; + if(status === 403) + throw 'you are not allowed to edit that part of the category'; + if(status === 404) + throw 'that category does not exist'; + if(status === 410) + throw 'that category disappeared while attempting to edit it'; + if(status !== 200) + throw 'something went wrong'; - return status; + return body; }, getPost: async post => { if(typeof post !== 'string') diff --git a/assets/misuzu.js/comments/form.jsx b/assets/misuzu.js/comments/form.jsx index 67d614bf..67e4acfb 100644 --- a/assets/misuzu.js/comments/form.jsx +++ b/assets/misuzu.js/comments/form.jsx @@ -1,12 +1,10 @@ -const MszCommentsFormNotice = function() { +const MszCommentsFormNotice = function(body) { const element = <div class="comments-notice"> - You must be logged in to post comments. + <div class="comments-notice-inner">{body}</div> </div>; return { - get element() { - return element; - }, + get element() { return element; }, }; }; @@ -29,8 +27,6 @@ const MszCommentsForm = function(userInfo, root) { </form>; return { - get element() { - return element; - }, + get element() { return element; }, }; }; diff --git a/assets/misuzu.js/comments/listing.jsx b/assets/misuzu.js/comments/listing.jsx index 8e82604f..6abbbabb 100644 --- a/assets/misuzu.js/comments/listing.jsx +++ b/assets/misuzu.js/comments/listing.jsx @@ -1,205 +1,407 @@ +#include msgbox.jsx #include comments/api.js #include comments/form.jsx -const MszCommentsEntry = function(userInfo, postInfo, listing, root) { - userInfo ??= {}; +const MszCommentsEntryVoteButton = function(name, title, icon, vote) { + let element, counter; + const isCast = () => element?.classList.contains('comments-entry-action-vote-cast') === true; - const actions = <div class="comments-entry-actions" />; + element = <button class={`comments-entry-action comments-entry-action-vote-${name}`} onclick={() => { vote(isCast()); }}> + {icon} + </button>; - const likeAction = <button class="comments-entry-action comments-entry-action-vote-like" disabled={!userInfo.can_vote} title="Like"> - <i class="fas fa-chevron-up" /> - </button>; - const dislikeAction = <button class="comments-entry-action comments-entry-action-vote-dislike" disabled={!userInfo.can_vote} title="Dislike"> - <i class="fas fa-chevron-down" /> - </button>; - const voteActions = <div class="comments-entry-actions-group comments-entry-actions-group-votes"> - {likeAction} - {dislikeAction} + 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(vote) { + const like = new MszCommentsEntryVoteButton( + 'like', + '$0 like$s', + <i class="fas fa-chevron-up" />, + cast => { vote(cast ? 0 : 1); } + ); + const dislike = new MszCommentsEntryVoteButton( + 'dislike', + '$0 dislike$s', + <i class="fas fa-chevron-down" />, + cast => { vote(cast ? 0 : -1); } + ); + + const element = <div class="comments-entry-actions-group comments-entry-actions-group-votes"> + {like} + {dislike} </div>; - actions.appendChild(voteActions); - const updateVoteElem = (elem, count, cast) => { - elem.classList.toggle('comments-entry-action-vote-cast', cast); + return { + get element() { return element; }, - let counter = elem.querySelector('.js-votes'); - if(!counter) { - if(count === 0) - return; + 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; + }, - elem.appendChild(counter = <span class="js-votes" />); - } - - if(count === 0) - elem.removeChild(counter); - else - counter.textContent = count.toLocaleString(); + 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 updateVotes = votes => { - updateVoteElem(likeAction, votes?.positive ?? 0, votes?.vote > 0); - updateVoteElem(dislikeAction, Math.abs(votes?.negative ?? 0), votes?.vote < 0); +}; + +const MszCommentsEntryReplyToggleButton = function(replies, toggleReplies) { + let icon, counter; + const element = <button class="comments-entry-action" title="No replies" onclick={() => { toggleReplies(); }}> + {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); }; - updateVotes(postInfo); + setCount(Array.isArray(replies) ? replies.length : (replies ?? 0)); - const castVote = async vote => { - if(postInfo.deleted) + 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(toggleForm) { + const element = <button class="comments-entry-action" title="Reply" onclick={() => { toggleForm(); }}> + <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); }, + }; +}; + +const MszCommentsEntryReplyActions = function(replies, toggleReplies, toggleForm) { + const toggle = new MszCommentsEntryReplyToggleButton(replies, toggleReplies); + const button = toggleForm ? new MszCommentsEntryReplyCreateButton(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(icon, title, action) { + 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(deleteAction, restoreAction, nukeAction, pinAction, unpinAction) { + let deleteButton, restoreButton, nukeButton, pinButton, unpinButton; + const element = <div class="comments-entry-actions-group"> + {deleteButton = deleteAction ? new MszCommentsEntryGeneralButton(<i class="fas fa-trash" />, 'Delete', deleteAction) : null} + {restoreButton = restoreAction ? new MszCommentsEntryGeneralButton(<i class="fas fa-trash-restore" />, 'Restore', restoreAction) : null} + {nukeButton = nukeAction ? new MszCommentsEntryGeneralButton(<i class="fas fa-radiation-alt" />, 'Permanently delete', nukeAction) : null} + {pinButton = pinAction ? new MszCommentsEntryGeneralButton(<i class="fas fa-thumbtack" />, 'Pin', pinAction) : null} + {unpinButton = unpinAction ? new MszCommentsEntryGeneralButton(<i class="fas fa-screwdriver" />, 'Unpin', unpinAction) : null} + </div>; + + return { + get element() { return element; }, + + get visible() { return !element.classList.contains('hidden'); }, + set visible(state) { setVisible(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(async vote => { + if(voteActions.disabled) return; - voteActions.classList.add('comments-entry-actions-group-disabled'); - likeAction.disabled = dislikeAction.disabled = true; + voteActions.disabled = true; try { - updateVotes(vote === 0 + voteActions.updateVotes(vote === 0 ? await MszCommentsApi.deleteVote(postInfo.id) : await MszCommentsApi.createVote(postInfo.id, vote)); } catch(ex) { console.error(ex); } finally { - voteActions.classList.remove('comments-entry-actions-group-disabled'); - likeAction.disabled = dislikeAction.disabled = false; + voteActions.disabled = false; } - }; + }); + actions.appendGroup(voteActions); - likeAction.onclick = () => { castVote(likeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : 1); }; - dislikeAction.onclick = () => { castVote(dislikeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : -1); }; + voteActions.disabled = !userInfo.can_vote || !!postInfo.deleted; + voteActions.updateVotes(postInfo); const repliesIsArray = Array.isArray(postInfo.replies); const replies = new MszCommentsListing({ hidden: !repliesIsArray }); if(repliesIsArray) - replies.addPosts(userInfo, postInfo.replies); + replies.addPosts(catInfo, userInfo, postInfo.replies); - let form = null; const repliesElem = <div class="comments-entry-replies"> {replies} </div>; - let replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0); - const replyActionsGroup = <div class="comments-entry-actions-group comments-entry-actions-group-replies" />; - actions.appendChild(replyActionsGroup); + let replyForm; + let repliesLoaded = replies.loaded; + const replyActions = new MszCommentsEntryReplyActions( + postInfo.replies, + 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; + } + } + }, + userInfo.can_create ? () => { + if(replyForm) { + replyActions.button.active = false; + repliesElem.removeChild(replyForm.element); + replyForm = null; + } else { + replyActions.button.active = true; + replyForm = new MszCommentsForm(userInfo); + $insertBefore(replies.element, replyForm.element); + } + } : null, + ); + actions.appendGroup(replyActions); - const replyToggleOpenElem = <span class="hidden"><i class="fas fa-minus" /></span>; - const replyToggleClosedElem = <span class="hidden"><i class="fas fa-plus" /></span>; - const replyCountElem = <span />; - const replyToggleElem = <button class="comments-entry-action" title="Replies"> - {replyToggleOpenElem} - {replyToggleClosedElem} - {replyCountElem} - </button>; - replyActionsGroup.appendChild(replyToggleElem); + replyActions.toggle.open = replies.visible; + if(replyActions.button) + replyActions.button.visible = !catInfo.locked; + replyActions.updateVisible(); - const setReplyToggleState = visible => { - replyToggleOpenElem.classList.toggle('hidden', !visible); - replyToggleClosedElem.classList.toggle('hidden', visible); - }; - setReplyToggleState(replies.visible); - - const setReplyCount = count => { - replyCount = count ??= 0; - if(count > 0) { - replyCountElem.textContent = count.toLocaleString(); - replyToggleElem.classList.remove('hidden'); - replyActionsGroup.classList.remove('hidden'); - } else { - replyToggleElem.classList.add('hidden'); - if(replyActionsGroup.childElementCount < 2) - replyActionsGroup.classList.add('hidden'); - } - }; - - let replyLoaded = replies.loaded; - replyToggleElem.onclick = async () => { - setReplyToggleState(replies.visible = !replies.visible); - if(!replyLoaded) { - replyLoaded = true; + const generalActions = new MszCommentsEntryGeneralActions( + postInfo.can_delete ? async () => { + generalActions.disabled = true; try { - replies.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id)); + postInfo.deleted = new Date; + await MszCommentsApi.deletePost(postInfo.id); + if(restoreButton) { + setOptionalTime(deletedElem, new Date, 'commentDeleted'); + generalActions.deleteVisible = false; + listing.reorder(); + } else + nukeThePost(); + } catch(ex) { + delete postInfo.deleted; + console.error(ex); + } finally { + generalActions.disabled = false; + } + } : null, + 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; + voteActions.disabled = false; + listing.reorder(); + } catch(ex) { + postInfo.deleted = deleted; + console.error(ex); + } finally { + generalActions.disabled = false; + } + } : null, + postInfo.can_delete_any ? async () => { + generalActions.disabled = true; + try { + await MszCommentsApi.nukePost(postInfo.id); + nukeThePost(); } catch(ex) { console.error(ex); - setReplyToggleState(false); - replyLoaded = false; - - // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE - if(typeof ex === 'string') - MszShowMessageBox(ex); + } finally { + generalActions.disabled = false; } - } - }; + } : null, + 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; - if(userInfo.can_create) { - const replyElem = <button class="comments-entry-action" title="Reply"> - <span><i class="fas fa-reply" /></span> - <span>Reply</span> - </button>; - replyActionsGroup.appendChild(replyElem); - - replyElem.onclick = () => { - if(form === null) { - replyElem.classList.add('comments-entry-action-reply-active'); - form = new MszCommentsForm(userInfo); - $insertBefore(replies.element, form.element); - } else { - replyElem.classList.remove('comments-entry-action-reply-active'); - repliesElem.removeChild(form.element); - form = null; + const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '1' }); + generalActions.pinVisible = !result.pinned; + setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); + listing.reorder(); + } catch(ex) { + console.error(ex); + } finally { + generalActions.disabled = false; } - }; - } + } : null, + root && userInfo.can_pin ? async () => { + generalActions.disabled = true; + try { + const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '0' }); + generalActions.pinVisible = !result.pinned; + setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); + listing.reorder(); + } catch(ex) { + console.error(ex); + } finally { + generalActions.disabled = false; + } + } : null, + ); + actions.appendGroup(generalActions); - // this has to be called no earlier cus if there's less than 2 elements in the group it gets hidden on 0 - setReplyCount(replyCount); - - const deleteButton = postInfo.can_delete - ? <button class="comments-entry-action" title="Delete"><i class="fas fa-trash" /></button> - : null; - const restoreButton = postInfo.can_delete_any - ? <button class="comments-entry-action" title="Restore"><i class="fas fa-trash-restore" /></button> - : null; - const nukeButton = postInfo.can_delete_any - ? <button class="comments-entry-action" title="Permanently delete"><i class="fas fa-radiation-alt" /></button> - : null; - const pinButton = root && userInfo.can_pin - ? <button class="comments-entry-action" title="Pin"><i class="fas fa-thumbtack" /></button> - : null; - const unpinButton = root && userInfo.can_pin - ? <button class="comments-entry-action" title="Unpin"> - {/*<i class="fas fa-crow" /> - <i class="fas fa-bars" />*/} - <img src="https://mikoto.misaka.nl/u/1I0KnhRO/crowbar.png" width="11" height="12" alt="crowbar" /> - </button> : null; - - const miscActions = <div class="comments-entry-actions-group hidden"> - {deleteButton} - {restoreButton} - {nukeButton} - {pinButton} - {unpinButton} - </div>; - actions.appendChild(miscActions); - - const setMiscVisible = (deleted=null, pinned=null) => { - if(deleted !== null) { - if(deleteButton) - deleteButton.classList.toggle('hidden', deleted); - if(restoreButton) - restoreButton.classList.toggle('hidden', !deleted); - if(nukeButton) - nukeButton.classList.toggle('hidden', !deleted); - } - if(pinned !== null) { - if(pinButton) - pinButton.classList.toggle('hidden', pinned); - if(unpinButton) - unpinButton.classList.toggle('hidden', !pinned); - } - - miscActions.classList.toggle('hidden', miscActions.querySelectorAll('.hidden').length === miscActions.childElementCount); - }; - const setMiscDisabled = state => { - miscActions.classList.toggle('comments-entry-actions-group-disabled', state); - for(const elem of miscActions.querySelectorAll('button')) - elem.disabled = state; - }; - - setMiscVisible(!!postInfo.deleted, !!postInfo.pinned); + 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" />; @@ -237,8 +439,8 @@ const MszCommentsEntry = function(userInfo, postInfo, listing, root) { {pinnedElem} {deletedElem} </div> - <div class="comments-entry-body">{postInfo?.body ?? '[deleted]'}</div> - {actions.childElementCount > 0 ? actions : null} + {bodyElem} + {actions} </div> </div> {repliesElem} @@ -323,140 +525,92 @@ const MszCommentsEntry = function(userInfo, postInfo, listing, root) { updateOrderValue(); const nukeThePost = () => { - if(replies.count < 1 && replyCount < 1) + if(replies.count < 1 && replyActions.toggle.count < 1) listing.element.removeChild(element); else { - miscActions.classList.add('hidden'); - setMiscDisabled(true); + generalActions.visible = false; + voteActions.disabled = true; + voteActions.updateVotes(); + generalActions.disabled = true; setUserInfo(null); setBody(null); + setOptionalTime(deletedElem, true, 'commentDeleted', true, 'deleted'); + listing.reorder(); } }; - if(deleteButton) - deleteButton.onclick = async () => { - setMiscDisabled(true); - try { - await MszCommentsApi.deletePost(postInfo.id); - if(restoreButton) { - setOptionalTime(deletedElem, new Date, 'commentDeleted'); - listing.reorder(); - deleteButton.classList.add('hidden'); - restoreButton.classList.remove('hidden'); - nukeButton.classList.remove('hidden'); - } else - nukeThePost(); - } catch(ex) { - console.error(ex); - } finally { - setMiscDisabled(false); - } - }; - if(restoreButton) - restoreButton.onclick = async () => { - setMiscDisabled(true); - try { - await MszCommentsApi.restorePost(postInfo.id); - setMiscVisible(false, null); - setOptionalTime(deletedElem, null, 'commentDeleted'); - listing.reorder(); - } catch(ex) { - console.error(ex); - } finally { - setMiscDisabled(false); - } - }; - if(nukeButton) - nukeButton.onclick = async () => { - setMiscDisabled(true); - try { - await MszCommentsApi.nukePost(postInfo.id); - nukeThePost(); - } catch(ex) { - console.error(ex); - } finally { - setMiscDisabled(false); - } - }; - if(pinButton) - pinButton.onclick = async () => { - setMiscDisabled(true); - try { - const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '1' }); - setMiscVisible(null, !!result.pinned); - setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); - listing.reorder(); - } catch(ex) { - console.error(ex); - } finally { - setMiscDisabled(false); - } - }; - if(unpinButton) - unpinButton.onclick = async () => { - setMiscDisabled(true); - try { - const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '0' }); - setMiscVisible(null, !!result.pinned); - setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); - listing.reorder(); - } catch(ex) { - console.error(ex); - } finally { - setMiscDisabled(false); - } - }; - return { get element() { return element; }, + + updateLocked() { + if(replyActions.button) { + replyActions.button.visible = !catInfo.locked; + replyActions.updateVisible(); + } + + replies.updateLocked(); + }, }; }; const MszCommentsListing = function(options) { let { hidden, root } = options ?? {}; - let loading = new MszLoading; + 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 }}> - {loading} - </div>; + const element = <div class={{ 'comments-listing': true, 'comments-listing-root': root, 'hidden': hidden }} />; - const listing = { + const pub = { get element() { return element; }, - get count() { return element.childElementCount; }, + 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; }, - reorder: () => { + 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) + entries.updateLocked(); + }, - addPost: (userInfo, postInfo, parentId=null) => { - const entry = new MszCommentsEntry(userInfo ?? {}, postInfo, listing, root); + addPost(catInfo, userInfo, postInfo, parentId=null) { + const entry = new MszCommentsEntry(catInfo ?? {}, userInfo ?? {}, postInfo, pub, root); entries.set(postInfo.id, entry); element.appendChild(entry.element); }, - addPosts: (userInfo, posts) => { + addPosts(catInfo, userInfo, posts) { try { if(!Array.isArray(posts)) throw 'posts must be an array'; + catInfo ??= {}; userInfo ??= {}; for(const postInfo of posts) - listing.addPost(userInfo, postInfo); + pub.addPost(catInfo, userInfo, postInfo); } finally { - loading.element.remove(); - loading = null; + pub.removeLoading(); } }, }; - return listing; + 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 index 30cf9a23..aa564aec 100644 --- a/assets/misuzu.js/comments/section.jsx +++ b/assets/misuzu.js/comments/section.jsx @@ -2,43 +2,81 @@ #include comments/api.js #include comments/form.jsx #include comments/listing.jsx +#include comments/options.jsx -const MszCommentsSection = function(options) { - let { category: catName } = options ?? {}; +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; - MszCommentsApi.getCategory(catName) - .then(catInfo => { - console.log(catInfo); + const clearForm = () => { + if(form) { + element.removeChild(form.element); + form = undefined; + } + }; + const setForm = elem => { + clearForm(); + form = elem; + $insertBefore(element.firstChild, form.element); + }; + const initForm = (user, category) => { + if(!user) + setForm(new MszCommentsFormNotice('You must be logged in to post comments.')); + else if(!user.can_create) + setForm(new MszCommentsFormNotice('You are not allowed to comment.')); + else if(category.locked) + setForm(new MszCommentsFormNotice('This comment section is closed.')); + else + setForm(new MszCommentsForm(user, true)); + }; - let formElement; - if(catInfo.user?.can_create) { - form = new MszCommentsForm(catInfo.user, true); - formElement = form.element; - } else - formElement = (new MszCommentsFormNotice).element; + const pub = { + get element() { return element; }, - $insertBefore(listing.element, formElement); + async reload() { + clearForm(); + listing.reset(); - listing.addPosts(catInfo.user, catInfo.posts); - }) - .catch(message => { - console.error(message); + try { + const { user, category, posts } = await MszCommentsApi.getCategory(catName); - // THIS IS NOT FINAL DO NOT PUSH THIS TO PUBLIC THIS WOULD BE HORRIBLE - if(typeof message === 'string') - MszShowMessageBox(message); - }); + retryAct = undefined; + options.reset(); - return { - get element() { - return element; + initForm(user, category); + + if(user?.can_lock) + options.appendAction(new MszCommentsOptionsLockAction( + category, + () => { + initForm(user, category); + } + )); + + listing.addPosts(category, user, posts); + } catch(ex) { + console.error(ex); + listing.removeLoading(); + + form = new MszCommentsFormNotice('Failed to load comments.'); + $insertBefore(element.firstChild, form.element); + + if(!retryAct) + options.appendAction(retryAct = new MszCommentsOptionsRetryAction(pub)); + } }, }; + + pub.reload(); + + return pub; }; diff --git a/build.js b/build.js index 6c9a5ee4..afbcca1b 100644 --- a/build.js +++ b/build.js @@ -13,7 +13,11 @@ const fs = require('fs'); swc: { es: 'es2021', jsx: '$jsx', + jsxf: '$jsxf', }, + housekeep: [ + pathJoin(__dirname, 'public', 'assets'), + ], }; const tasks = { 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/src/Comments/CommentsCategoriesData.php b/src/Comments/CommentsCategoriesData.php index b935ad70..ef1d6713 100644 --- a/src/Comments/CommentsCategoriesData.php +++ b/src/Comments/CommentsCategoriesData.php @@ -190,58 +190,38 @@ class CommentsCategoriesData { } public function updateCategory( - CommentsCategoryInfo|string $category, + CommentsCategoryInfo|string $infoOrId, ?string $name = null, - bool $updateOwner = false, - UserInfo|string|null $owner = null + ?bool $locked = null, + UserInfo|string|false|null $ownerInfo = null ): void { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; - if($owner instanceof UserInfo) - $owner = $owner->id; + $fields = []; + $values = []; if($name !== null) { - $name = trim($name); - if(empty($name)) - throw new InvalidArgumentException('$name may not be empty.'); + if(trim($name) === '') + throw new InvalidArgumentException('$name must be null or a non-empty string.'); + + $fields[] = 'category_name = ?'; + $values[] = $name; } - $stmt = $this->cache->get(<<<SQL - UPDATE msz_comments_categories - SET category_name = COALESCE(?, category_name), - user_id = IF(?, ?, user_id) - WHERE category_id = ? - SQL); - $stmt->nextParameter($name); - $stmt->nextParameter($updateOwner ? 1 : 0); - $stmt->nextParameter($owner ? 1 : 0); - $stmt->nextParameter($category); - $stmt->execute(); - } + if($locked !== null) + $fields[] = $locked ? 'category_locked = COALESCE(category_locked, NOW())' : 'category_locked = NULL'; - public function lockCategory(CommentsCategoryInfo|string $category): void { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; + 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(<<<SQL - UPDATE msz_comments_categories - SET category_locked = COALESCE(category_locked, NOW()) - WHERE category_id = ? - SQL); - $stmt->nextParameter($category); - $stmt->execute(); - } - - public function unlockCategory(CommentsCategoryInfo|string $category): void { - if($category instanceof CommentsCategoryInfo) - $category = $category->id; - - $stmt = $this->cache->get(<<<SQL - UPDATE msz_comments_categories - SET category_locked = NULL - WHERE category_id = ? - SQL); - $stmt->nextParameter($category); + $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 406c9eef..8c4ceb9c 100644 --- a/src/Comments/CommentsCategoryInfo.php +++ b/src/Comments/CommentsCategoryInfo.php @@ -35,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/CommentsPostsData.php b/src/Comments/CommentsPostsData.php index dfd1edae..0ecf9455 100644 --- a/src/Comments/CommentsPostsData.php +++ b/src/Comments/CommentsPostsData.php @@ -241,9 +241,6 @@ class CommentsPostsData { ?bool $pinned, bool $edited = false ): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; - $fields = []; $values = []; @@ -267,7 +264,7 @@ class CommentsPostsData { $stmt = $this->cache->get(sprintf('UPDATE msz_comments_posts SET %s WHERE comment_id = ?', implode(', ', $fields))); foreach($values as $value) $stmt->nextParameter($value); - $stmt->nextParameter($infoOrId); + $stmt->nextParameter($infoOrId instanceof CommentsPostInfo ? $infoOrId->id : $infoOrId); $stmt->execute(); } } diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php index 5b6a805e..824cfd0c 100644 --- a/src/Comments/CommentsRoutes.php +++ b/src/Comments/CommentsRoutes.php @@ -44,6 +44,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { private function convertPosts( IPermissionResult $perms, + CommentsCategoryInfo $catInfo, iterable $postInfos, bool $loadReplies = false ): array { @@ -52,6 +53,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { foreach($postInfos as $postInfo) { $post = $this->convertPost( $perms, + $catInfo, $postInfo, $loadReplies ? $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) : null ); @@ -66,6 +68,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { private function convertPost( IPermissionResult $perms, + CommentsCategoryInfo $catInfo, CommentsPostInfo $postInfo, ?iterable $replyInfos = null ): array { @@ -102,7 +105,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { $isAuthor = $this->authInfo->userId === $postInfo->userId; if($isAuthor && $perms->check(Perm::G_COMMENTS_EDIT_OWN)) $post['can_edit'] = true; - if($isAuthor && $perms->check(Perm::G_COMMENTS_DELETE_OWN)) + if(($isAuthor || $catInfo->ownerId === $this->authInfo->userId) && $perms->check(Perm::G_COMMENTS_DELETE_OWN)) $post['can_delete'] = true; } } @@ -121,7 +124,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { if($replies > 0) $post['replies'] = $replies; } else { - $replies = $this->convertPosts($perms, $replyInfos); + $replies = $this->convertPosts($perms, $catInfo, $replyInfos); if(!empty($replies)) $post['replies'] = $replies; } @@ -145,7 +148,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { #[HttpGet('/comments/categories/([A-Za-z0-9-]+)')] public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): int|array { try { - $categoryInfo = $this->commentsCtx->categories->getCategory(name: $categoryName); + $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName); } catch(RuntimeException $ex) { return 404; } @@ -153,17 +156,17 @@ class CommentsRoutes implements RouteHandler, UrlSource { $perms = $this->getGlobalPerms(); $result = []; $category = [ - 'name' => $categoryInfo->name, - 'created' => $categoryInfo->createdAt->toIso8601ZuluString(), + 'name' => $catInfo->name, + 'created' => $catInfo->createdAt->toIso8601ZuluString(), ]; - if($categoryInfo->locked) - $category['locked'] = $categoryInfo->lockedAt->toIso8601ZuluString(); + if($catInfo->locked) + $category['locked'] = $catInfo->lockedAt->toIso8601ZuluString(); - if($categoryInfo->ownerId !== null) + if($catInfo->ownerId !== null) try { $category['owner'] = $this->convertUser( - $this->usersCtx->getUserInfo($categoryInfo->ownerId) + $this->usersCtx->getUserInfo($catInfo->ownerId) ); } catch(RuntimeException $ex) {} @@ -185,8 +188,8 @@ class CommentsRoutes implements RouteHandler, UrlSource { } try { - $posts = $this->convertPosts($perms, $this->commentsCtx->posts->getPosts( - categoryInfo: $categoryInfo, + $posts = $this->convertPosts($perms, $catInfo, $this->commentsCtx->posts->getPosts( + categoryInfo: $catInfo, replies: false, ), true); } catch(RuntimeException $ex) { @@ -198,12 +201,43 @@ class CommentsRoutes implements RouteHandler, UrlSource { return $result; } - #[HttpPatch('/comments/categories/([A-Za-z0-9-]+)')] + #[HttpPost('/comments/categories/([A-Za-z0-9-]+)')] public function patchCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): int|array { - if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_LOCK)) - return 403; + if(!($request->content instanceof FormHttpContent)) + return 400; - return 501; + try { + $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName); + } catch(RuntimeException $ex) { + return 404; + } + + $perms = $this->getGlobalPerms(); + $locked = null; + + if($request->content->hasParam('lock')) { + if(!$perms->check(Perm::G_COMMENTS_LOCK)) + return 403; + + $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 410; + } + + $result = ['name' => $catInfo->name]; + if($locked !== null) + $result['locked'] = $catInfo->locked ? $catInfo->lockedAt->toIso8601ZuluString() : false; + + return $result; } #[HttpPost('/comments/posts')] @@ -222,8 +256,19 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 404; } + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + } catch(RuntimeException $ex) { + return 404; + } + $perms = $this->getGlobalPerms(); - $post = $this->convertPost($perms, $postInfo, $this->commentsCtx->posts->getPosts(parentInfo: $postInfo)); + $post = $this->convertPost( + $perms, + $catInfo, + $postInfo, + $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) + ); if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies'])) return 404; @@ -238,8 +283,15 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 404; } + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + } catch(RuntimeException $ex) { + return 404; + } + return $this->convertPosts( $this->getGlobalPerms(), + $catInfo, $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) ); } @@ -257,9 +309,14 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 404; } - $perms = $this->getGlobalPerms(); + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + } catch(RuntimeException $ex) { + return 404; + } - if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && $postInfo->deleted) + $perms = $this->getGlobalPerms(); + if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && ($catInfo->locked || $postInfo->deleted)) return 404; $body = null; @@ -267,7 +324,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { $edited = false; if($request->content->hasParam('pin')) { - if(!$perms->check(Perm::G_COMMENTS_PIN)) + if(!$perms->check(Perm::G_COMMENTS_PIN) || $catInfo->ownerId !== $this->authInfo->userId) return 403; $pinned = !empty($request->content->getParam('pin')); @@ -311,17 +368,25 @@ class CommentsRoutes implements RouteHandler, UrlSource { public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); + if($postInfo->deleted) + return 404; } catch(RuntimeException $ex) { return 404; } - if($postInfo->deleted) + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + if($catInfo->locked) + return 403; + } catch(RuntimeException $ex) { return 404; + } $perms = $this->getGlobalPerms(); - if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) - && !($postInfo->userId === $this->authInfo->userId && $perms->check(Perm::G_COMMENTS_DELETE_OWN))) - return 403; + 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 403; $this->commentsCtx->posts->deletePost($postInfo); @@ -335,12 +400,19 @@ class CommentsRoutes implements RouteHandler, UrlSource { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); + if(!$postInfo->deleted) + return 400; } catch(RuntimeException $ex) { return 404; } - if(!$postInfo->deleted) - return 400; + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + if($catInfo->locked) + return 403; + } catch(RuntimeException $ex) { + return 404; + } $this->commentsCtx->posts->restorePost($postInfo); @@ -354,12 +426,19 @@ class CommentsRoutes implements RouteHandler, UrlSource { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); + if(!$postInfo->deleted) + return 400; } catch(RuntimeException $ex) { return 404; } - if(!$postInfo->deleted) - return 400; + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + if($catInfo->locked) + return 403; + } catch(RuntimeException $ex) { + return 404; + } $this->commentsCtx->posts->nukePost($postInfo); @@ -380,6 +459,16 @@ class CommentsRoutes implements RouteHandler, UrlSource { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); + if($postInfo->deleted) + return 404; + } catch(RuntimeException $ex) { + return 404; + } + + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + if($catInfo->locked) + return 403; } catch(RuntimeException $ex) { return 404; } @@ -407,6 +496,16 @@ class CommentsRoutes implements RouteHandler, UrlSource { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); + if($postInfo->deleted) + return 404; + } catch(RuntimeException $ex) { + return 404; + } + + try { + $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); + if($catInfo->locked) + return 403; } catch(RuntimeException $ex) { return 404; }