From dc2ec0d6d40f5a44b8f62f71b261f1bb56465fd1 Mon Sep 17 00:00:00 2001 From: flashwave <me@flash.moe> Date: Thu, 20 Feb 2025 01:55:49 +0000 Subject: [PATCH] And that should be it. --- assets/common.css/loading.css | 5 + assets/common.js/html.js | 230 +++++------ assets/common.js/loading.jsx | 4 +- assets/misuzu.css/comments/entry.css | 12 +- assets/misuzu.css/comments/form.css | 63 ++- assets/misuzu.js/comments/api.js | 170 ++------ assets/misuzu.js/comments/form.jsx | 105 ++++- assets/misuzu.js/comments/init.js | 4 +- assets/misuzu.js/comments/listing.jsx | 186 +++++---- assets/misuzu.js/comments/section.jsx | 19 +- assets/misuzu.js/embed/audio.js | 368 ++++++++---------- assets/misuzu.js/embed/embed.js | 23 +- assets/misuzu.js/embed/image.js | 21 +- assets/misuzu.js/embed/video.js | 519 ++++++++++--------------- assets/misuzu.js/forum/editor.jsx | 16 +- assets/misuzu.js/main.js | 2 +- assets/misuzu.js/messages/list.js | 4 +- assets/misuzu.js/messages/messages.js | 6 +- assets/misuzu.js/messages/recipient.js | 2 +- assets/misuzu.js/messages/reply.jsx | 2 +- build.js | 4 +- src/Comments/CommentsPostInfo.php | 2 +- src/Comments/CommentsRoutes.php | 225 ++++++----- 23 files changed, 970 insertions(+), 1022 deletions(-) diff --git a/assets/common.css/loading.css b/assets/common.css/loading.css index 443a8507..b1adb578 100644 --- a/assets/common.css/loading.css +++ b/assets/common.css/loading.css @@ -5,6 +5,11 @@ 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; diff --git a/assets/common.js/html.js b/assets/common.js/html.js index 267d153e..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,147 +48,87 @@ const $removeChildren = function(element) { element.firstChild.remove(); }; -const $jsx = (type, props, ...children) => $create({ tag: type, attrs: props, child: children }); -const $jsxf = window.DocumentFragment; +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'); - let elem; + if(props) + for(let key in props) { + const prop = props[key]; + if(prop === undefined || prop === null) + continue; - if(typeof info.tag === 'function') { - elem = new info.tag(info.attrs || {}); - } else { - 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) - continue; - - switch(typeof attr) { - case 'function': - if(key.substring(0, 2) === 'on') - key = key.substring(2).toLowerCase(); - elem.addEventListener(key, attr); - break; - - case 'object': - if(attr instanceof Array) { - if(key === 'class') - key = 'classList'; - - const prop = elem[key]; - let addFunc = null; - - 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 === '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); - } - } - break; - - case 'boolean': - if(attr) - elem.setAttribute(key, ''); - break; - - default: - if(key === 'className') - key = 'class'; - elem.setAttribute(key, attr.toString()); - break; - } - } - } - } - - if(info.child) { - let children = info.child; - - if(!Array.isArray(children)) - children = [children]; - - for(const child of children) { - switch(typeof child) { - case 'undefined': - break; - - case 'string': - elem.appendChild(document.createTextNode(child)); + switch(typeof prop) { + case 'function': + if(key.substring(0, 2) === 'on') + key = key.substring(2).toLowerCase(); + element.addEventListener(key, prop); break; case 'object': - if(child === null) - break; + if(prop instanceof Array) { + if(key === 'class') + key = 'classList'; - if(child instanceof Node) { - elem.appendChild(child); - } else if('element' in child) { - const childElem = child.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 Node) - elem.appendChild(childElem); - else - elem.appendChild($create(child)); + const attr = element[key]; + let addFunc = null; + + 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 < prop.length; ++j) + addFunc(prop[j]); + } else { + if(key === 'classList') + key = 'class'; + element.setAttribute(key, prop.toString()); + } } else { - elem.appendChild($create(child)); + 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(prop) + element.setAttribute(key, ''); + break; + default: - elem.appendChild(document.createTextNode(child.toString())); + if(key === 'className') + key = 'class'; + + element.setAttribute(key, prop.toString()); break; } } - } - if(info.created) - info.created(elem); + $appendChildren(element, ...children); - return elem; + return element; }; diff --git a/assets/common.js/loading.jsx b/assets/common.js/loading.jsx index 515cf231..340f0e14 100644 --- a/assets/common.js/loading.jsx +++ b/assets/common.js/loading.jsx @@ -62,7 +62,7 @@ const MszLoading = function(options=null) { let { element, size, colour, - width, height, + width, height, inline, containerWidth, containerHeight, gap, margin, hidden, } = options ?? {}; @@ -74,6 +74,8 @@ const MszLoading = function(options=null) { 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'); diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css index f3b6d8c6..dea52377 100644 --- a/assets/misuzu.css/comments/entry.css +++ b/assets/misuzu.css/comments/entry.css @@ -1,9 +1,6 @@ -.comments-entry { - /**/ -} - .comments-entry-main { - display: flex; + display: grid; + grid-template-columns: 46px 1fr; gap: 2px; } @@ -18,7 +15,7 @@ .comments-entry-avatar { flex: 0 0 auto; - margin: 4px; + padding: 4px; } .comments-entry-wrap { flex: 0 1 auto; @@ -68,9 +65,6 @@ text-decoration: underline; } -.comments-entry-body { -} - .comments-entry-actions { display: flex; gap: 2px; diff --git a/assets/misuzu.css/comments/form.css b/assets/misuzu.css/comments/form.css index f8f53dae..101054f3 100644 --- a/assets/misuzu.css/comments/form.css +++ b/assets/misuzu.css/comments/form.css @@ -1,26 +1,75 @@ .comments-form { border: 1px solid var(--accent-colour); border-radius: 3px; - display: flex; - gap: 2px; + 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; - margin: 3px; + padding: 3px; } .comments-form-wrap { - flex: 0 1 auto; - display: flex; - flex-direction: column; + display: grid; + grid-template-rows: 1fr 32px; gap: 2px; + margin: 3px; + margin-left: 0; + overflow: hidden; } .comments-form-input { - display: flex; + 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.js/comments/api.js b/assets/misuzu.js/comments/api.js index 0c99ce8d..5bb680d6 100644 --- a/assets/misuzu.js/comments/api.js +++ b/assets/misuzu.js/comments/api.js @@ -1,226 +1,142 @@ const MszCommentsApi = (() => { return { getCategory: async name => { - if(typeof name !== 'string') - throw 'name must be a string'; - if(name.trim() === '') - throw 'name may not be empty'; + 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 === 404) - throw 'that category does not exist'; if(status !== 200) - throw 'something went wrong'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); return body; }, updateCategory: async (name, args) => { - if(typeof name !== 'string') - throw 'name must be a string'; - if(name.trim() === '') - throw 'name may not be empty'; + if(typeof name !== 'string' || name.trim() === '') + throw new Error('name is not a valid category name'); if(typeof args !== 'object' || args === null) - throw 'args must be a non-null object'; + 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 === 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'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); return body; }, getPost: async post => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + 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 === 404) - throw 'that post does not exist'; if(status !== 200) - throw 'something went wrong'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); return body; }, getPostReplies: async post => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + 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 === 404) - throw 'that post does not exist'; if(status !== 200) - throw 'something went wrong'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); return body; }, - createPost: async (post, args) => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + createPost: async args => { if(typeof args !== 'object' || args === null) - throw 'args must be a non-null object'; + throw new Error('args must be a non-null object'); const { status, body } = await $xhr.post( '/comments/posts', - { csrf: true }, + { csrf: true, type: 'json' }, args ); + if(status !== 201) + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); - return status; + return body; }, updatePost: async (post, args) => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + if(typeof post !== 'string' || post.trim() === '') + throw new Error('post is not a valid post id'); if(typeof args !== 'object' || args === null) - throw 'args must be a non-null object'; + 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 === 400) - throw 'your update is not acceptable'; - if(status === 401) - throw 'you must be logged in to do that'; - if(status === 403) - throw 'you are not allowed to edit that part of the post'; - if(status === 404) - throw 'that post does not exist'; - if(status === 410) - throw 'that post disappeared while attempting to edit it'; if(status !== 200) - throw 'something went wrong'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); return body; }, deletePost: async post => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + if(typeof post !== 'string' || post.trim() === '') + throw new Error('post is not a valid post id'); - const { status } = await $xhr.delete(`/comments/posts/${post}`, { csrf: true }); - if(status === 401) - throw 'you must be logged in to do that'; - if(status === 403) - throw 'you are not allowed to delete that post'; - if(status === 404) - throw 'that post does not exist'; + const { status, body } = await $xhr.delete(`/comments/posts/${post}`, { csrf: true, type: 'json' }); if(status !== 204) - throw 'something went wrong'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); }, restorePost: async post => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + if(typeof post !== 'string' || post.trim() === '') + throw new Error('post is not a valid post id'); - const { status } = await $xhr.post(`/comments/posts/${post}/restore`, { csrf: true }); - if(status === 400) - throw 'that post is not deleted'; - if(status === 401) - throw 'you must be logged in to do that'; - if(status === 403) - throw 'you are not allowed to restore posts'; - if(status === 404) - throw 'that post does not exist'; + const { status, body } = await $xhr.post(`/comments/posts/${post}/restore`, { csrf: true, type: 'json' }); if(status !== 200) - throw 'something went wrong'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); }, nukePost: async post => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + 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 }); - if(status === 400) - throw 'that post is not deleted'; - if(status === 401) - throw 'you must be logged in to do that'; - if(status === 403) - throw 'you are not allowed to nuke posts'; - if(status === 404) - throw 'that post does not exist'; + const { status } = await $xhr.post(`/comments/posts/${post}/nuke`, { csrf: true, type: 'json' }); if(status !== 200) - throw 'something went wrong'; + throw new Error(body.error?.text ?? 'something went wrong', { cause: body.error?.name ?? 'something' }); }, createVote: async (post, vote) => { - if(typeof post !== 'string') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + 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 'vote must be a number'; + 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 === 400) - throw 'your vote is not acceptable'; - if(status === 401) - throw 'you must be logged in to do that'; - if(status === 403) - throw 'you are not allowed to like or dislike comments'; - if(status === 404) - throw 'that post does not exist'; - if(status !== 200) - throw 'something went wrong'; + 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') - throw 'post id must be a string'; - if(post.trim() === '') - throw 'post id may not be empty'; + 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 === 401) - throw 'you must be logged in to do that'; - if(status === 403) - throw 'you are not allowed to like or dislike comments'; - if(status === 404) - throw 'that post does not exist'; if(status !== 200) - throw 'something went wrong'; + 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 index 67e4acfb..7be77906 100644 --- a/assets/misuzu.js/comments/form.jsx +++ b/assets/misuzu.js/comments/form.jsx @@ -1,4 +1,7 @@ -const MszCommentsFormNotice = function(body) { +#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>; @@ -8,25 +11,109 @@ const MszCommentsFormNotice = function(body) { }; }; -const MszCommentsForm = function(userInfo, root) { - const element = <form class="comments-form" style={`--user-colour: ${userInfo.colour}; display: flex;`}> +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" placeholder="Share your extensive insights..." /> + <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 style="display: flex;"> - <div>Press enter to submit, use shift+enter to start a new line.</div> - <div style="flex-grow: 1;" /> - {userInfo.can_pin ? <div><label><input type="checkbox"/> Pin</label></div> : null} - <div><button class="input__button">Post</button></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 index 0abddf60..9448e182 100644 --- a/assets/misuzu.js/comments/init.js +++ b/assets/misuzu.js/comments/init.js @@ -3,9 +3,7 @@ const MszCommentsInit = () => { const targets = Array.from($queryAll('.js-comments')); for(const target of targets) { - const section = new MszCommentsSection({ - category: target.dataset.category, - }); + 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 index 6abbbabb..2fa0fbe1 100644 --- a/assets/misuzu.js/comments/listing.jsx +++ b/assets/misuzu.js/comments/listing.jsx @@ -2,7 +2,9 @@ #include comments/api.js #include comments/form.jsx -const MszCommentsEntryVoteButton = function(name, title, icon, vote) { +const MszCommentsEntryVoteButton = function(args) { + const { name, title, icon, vote } = args ?? {}; + let element, counter; const isCast = () => element?.classList.contains('comments-entry-action-vote-cast') === true; @@ -44,19 +46,21 @@ const MszCommentsEntryVoteButton = function(name, title, icon, vote) { }; }; -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 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} @@ -81,9 +85,11 @@ const MszCommentsEntryVoteActions = function(vote) { }; }; -const MszCommentsEntryReplyToggleButton = function(replies, toggleReplies) { +const MszCommentsEntryReplyToggleButton = function(args) { + const { replies, toggle } = args ?? {}; + let icon, counter; - const element = <button class="comments-entry-action" title="No replies" onclick={() => { toggleReplies(); }}> + const element = <button class="comments-entry-action" title="No replies" onclick={() => { toggle(); }}> {icon = <i class="fas fa-plus" />} {counter = <span />} </button>; @@ -118,8 +124,10 @@ const MszCommentsEntryReplyToggleButton = function(replies, toggleReplies) { }; }; -const MszCommentsEntryReplyCreateButton = function(toggleForm) { - const element = <button class="comments-entry-action" title="Reply" onclick={() => { toggleForm(); }}> +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>; @@ -132,12 +140,18 @@ const MszCommentsEntryReplyCreateButton = function(toggleForm) { 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(replies, toggleReplies, toggleForm) { - const toggle = new MszCommentsEntryReplyToggleButton(replies, toggleReplies); - const button = toggleForm ? new MszCommentsEntryReplyCreateButton(toggleForm) : undefined; +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} @@ -163,7 +177,8 @@ const MszCommentsEntryReplyActions = function(replies, toggleReplies, toggleForm }; }; -const MszCommentsEntryGeneralButton = function(icon, title, action) { +const MszCommentsEntryGeneralButton = function(args) { + const { icon, title, action } = args ?? {}; const element = <button class="comments-entry-action" title={title} onclick={() => { action(); }}>{icon}</button>; return { @@ -177,21 +192,21 @@ const MszCommentsEntryGeneralButton = function(icon, title, action) { }; }; -const MszCommentsEntryGeneralActions = function(deleteAction, restoreAction, nukeAction, pinAction, unpinAction) { +const MszCommentsEntryGeneralActions = function(args) { 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} + {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) { setVisible(state); }, + set visible(state) { element.classList.toggle('hidden', !state); }, get disabled() { return element.classList.contains('comments-entry-actions-group-disabled'); }, set disabled(state) { @@ -256,24 +271,30 @@ const MszCommentsEntryActions = function() { const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { const actions = new MszCommentsEntryActions; - const voteActions = new MszCommentsEntryVoteActions(async vote => { - if(voteActions.disabled) - return; + 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 { - voteActions.disabled = false; + 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); - voteActions.disabled = !userInfo.can_vote || !!postInfo.deleted; + const enableVoteActionsMaybe = () => { + voteActions.disabled = !userInfo.can_vote || !!postInfo.deleted || !!catInfo.locked; + }; + + enableVoteActionsMaybe(); voteActions.updateVotes(postInfo); const repliesIsArray = Array.isArray(postInfo.replies); @@ -287,9 +308,9 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { let replyForm; let repliesLoaded = replies.loaded; - const replyActions = new MszCommentsEntryReplyActions( - postInfo.replies, - async () => { + const replyActions = new MszCommentsEntryReplyActions({ + replies: postInfo.replies, + toggleReplies: async () => { replyActions.toggle.open = replies.visible = !replies.visible; if(!repliesLoaded) { repliesLoaded = true; @@ -302,34 +323,49 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { } } }, - userInfo.can_create ? () => { + 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); + 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); - replyActions.toggle.open = replies.visible; - if(replyActions.button) - replyActions.button.visible = !catInfo.locked; - replyActions.updateVisible(); + const enableReplyButtonMaybe = () => { + if(replyActions.button) + replyActions.button.visible = !catInfo.locked && !postInfo.deleted; + replyActions.updateVisible(); + }; - const generalActions = new MszCommentsEntryGeneralActions( - postInfo.can_delete ? async () => { + 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(restoreButton) { + if(generalActions.restoreButton) { setOptionalTime(deletedElem, new Date, 'commentDeleted'); generalActions.deleteVisible = false; + enableVoteActionsMaybe(); + enableReplyButtonMaybe(); listing.reorder(); } else nukeThePost(); @@ -340,7 +376,7 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { generalActions.disabled = false; } } : null, - postInfo.can_delete_any ? async () => { + restore: postInfo.can_delete_any ? async () => { generalActions.disabled = true; const deleted = postInfo.deleted; try { @@ -348,7 +384,8 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { await MszCommentsApi.restorePost(postInfo.id); setOptionalTime(deletedElem, null, 'commentDeleted'); generalActions.deleteVisible = true; - voteActions.disabled = false; + enableVoteActionsMaybe(); + enableReplyButtonMaybe(); listing.reorder(); } catch(ex) { postInfo.deleted = deleted; @@ -357,7 +394,7 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { generalActions.disabled = false; } } : null, - postInfo.can_delete_any ? async () => { + nuke: postInfo.can_delete_any ? async () => { generalActions.disabled = true; try { await MszCommentsApi.nukePost(postInfo.id); @@ -368,13 +405,13 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { generalActions.disabled = false; } } : null, - root && userInfo.can_pin ? async () => { + 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: '1' }); + const result = await MszCommentsApi.updatePost(postInfo.id, { pin: 'on' }); generalActions.pinVisible = !result.pinned; setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); listing.reorder(); @@ -384,10 +421,10 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { generalActions.disabled = false; } } : null, - root && userInfo.can_pin ? async () => { + unpin: root && userInfo.can_pin ? async () => { generalActions.disabled = true; try { - const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '0' }); + const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '' }); generalActions.pinVisible = !result.pinned; setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); listing.reorder(); @@ -397,7 +434,7 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { generalActions.disabled = false; } } : null, - ); + }); actions.appendGroup(generalActions); generalActions.deleteVisible = !postInfo.deleted; @@ -529,9 +566,9 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { listing.element.removeChild(element); else { generalActions.visible = false; - voteActions.disabled = true; - voteActions.updateVotes(); generalActions.disabled = true; + enableVoteActionsMaybe(); + enableReplyButtonMaybe(); setUserInfo(null); setBody(null); setOptionalTime(deletedElem, true, 'commentDeleted', true, 'deleted'); @@ -540,16 +577,11 @@ const MszCommentsEntry = function(catInfo, userInfo, postInfo, listing, root) { }; return { - get element() { - return element; - }, + get element() { return element; }, updateLocked() { - if(replyActions.button) { - replyActions.button.visible = !catInfo.locked; - replyActions.updateVisible(); - } - + enableVoteActionsMaybe(); + enableReplyButtonMaybe(); replies.updateLocked(); }, }; @@ -590,10 +622,14 @@ const MszCommentsListing = function(options) { }, updateLocked() { for(const [, value] of entries) - entries.updateLocked(); + value.updateLocked(); }, - addPost(catInfo, userInfo, postInfo, parentId=null) { + 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); diff --git a/assets/misuzu.js/comments/section.jsx b/assets/misuzu.js/comments/section.jsx index aa564aec..b5e6ebb4 100644 --- a/assets/misuzu.js/comments/section.jsx +++ b/assets/misuzu.js/comments/section.jsx @@ -28,15 +28,15 @@ const MszCommentsSection = function(args) { 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.')); + 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(user, true)); + setForm(new MszCommentsForm({ userInfo, catInfo, listing })); }; const pub = { @@ -59,6 +59,7 @@ const MszCommentsSection = function(args) { category, () => { initForm(user, category); + listing.updateLocked(); } )); @@ -67,7 +68,7 @@ const MszCommentsSection = function(args) { console.error(ex); listing.removeLoading(); - form = new MszCommentsFormNotice('Failed to load comments.'); + form = new MszCommentsFormNotice({ body: 'Failed to load comments.' }); $insertBefore(element.firstChild, form.element); if(!retryAct) 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 1f3f7504..1322a6a8 100644 --- a/assets/misuzu.js/main.js +++ b/assets/misuzu.js/main.js @@ -53,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; 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/build.js b/build.js index afbcca1b..adbae5bb 100644 --- a/build.js +++ b/build.js @@ -12,8 +12,8 @@ const fs = require('fs'); debug: isDebug, swc: { es: 'es2021', - jsx: '$jsx', - jsxf: '$jsxf', + jsx: '$element', + jsxf: '$fragment', }, housekeep: [ pathJoin(__dirname, 'public', 'assets'), diff --git a/src/Comments/CommentsPostInfo.php b/src/Comments/CommentsPostInfo.php index 3f811ec9..3bd5ef48 100644 --- a/src/Comments/CommentsPostInfo.php +++ b/src/Comments/CommentsPostInfo.php @@ -31,7 +31,7 @@ class CommentsPostInfo { ); } - public bool $isReply { + public bool $reply { get => $this->replyingTo !== null; } diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php index 824cfd0c..e03496f7 100644 --- a/src/Comments/CommentsRoutes.php +++ b/src/Comments/CommentsRoutes.php @@ -132,25 +132,38 @@ class CommentsRoutes implements RouteHandler, UrlSource { 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 401; + 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 403; + 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): int|array { + public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): array { try { $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName); } catch(RuntimeException $ex) { - return 404; + return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.'); } $perms = $this->getGlobalPerms(); @@ -177,7 +190,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { if($perms->check(Perm::G_COMMENTS_CREATE)) $user['can_create'] = true; - if($perms->check(Perm::G_COMMENTS_PIN)) + 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; @@ -202,14 +215,14 @@ class CommentsRoutes implements RouteHandler, UrlSource { } #[HttpPost('/comments/categories/([A-Za-z0-9-]+)')] - public function patchCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): int|array { + public function patchCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryName): array { if(!($request->content instanceof FormHttpContent)) - return 400; + 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 404; + return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.'); } $perms = $this->getGlobalPerms(); @@ -217,7 +230,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { if($request->content->hasParam('lock')) { if(!$perms->check(Perm::G_COMMENTS_LOCK)) - return 403; + return self::error($response, 403, 'comments:lock-not-allowed', 'You are not allowed to lock this comment section.'); $locked = !empty($request->content->getParam('lock')); } @@ -230,7 +243,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { try { $catInfo = $this->commentsCtx->categories->getCategory(categoryId: $catInfo->id); } catch(RuntimeException $ex) { - return 410; + return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.'); } $result = ['name' => $catInfo->name]; @@ -241,25 +254,73 @@ class CommentsRoutes implements RouteHandler, UrlSource { } #[HttpPost('/comments/posts')] - public function postPost(HttpResponseBuilder $response, HttpRequest $request): int|array { - if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_CREATE)) - return 403; + 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.'); - return 501; - } + $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.'); - #[HttpGet('/comments/posts/([0-9]+)')] - public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { try { - $postInfo = $this->commentsCtx->posts->getPost($commentId); + $catInfo = $this->commentsCtx->categories->getCategory(name: (string)$request->content->getParam('category')); } catch(RuntimeException $ex) { - return 404; + 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 404; + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); } $perms = $this->getGlobalPerms(); @@ -270,23 +331,18 @@ class CommentsRoutes implements RouteHandler, UrlSource { $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) ); if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies'])) - return 404; + 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): int|array { + public function getPostReplies(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); - } catch(RuntimeException $ex) { - return 404; - } - - try { $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); } catch(RuntimeException $ex) { - return 404; + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); } return $this->convertPosts( @@ -299,42 +355,44 @@ class CommentsRoutes implements RouteHandler, UrlSource { // this should be HttpPatch but PHP doesn't parse into $_POST for PATCH... // fix this in the v3 router for index by just ignoring PHP's parsing altogether #[HttpPost('/comments/posts/([0-9]+)')] - public function patchPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + public function patchPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array { if(!($request->content instanceof FormHttpContent)) - return 400; + return self::error($response, 400, 'comments:content', 'Provided content could not be understood.'); try { $postInfo = $this->commentsCtx->posts->getPost($commentId); - } catch(RuntimeException $ex) { - return 404; - } - - try { $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); } catch(RuntimeException $ex) { - return 404; + 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 404; + 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 403; + 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 403; + 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.'); - $body = (string)$request->content->getParam('body'); $edited = $body !== $postInfo->body; if(!$edited) $body = null; @@ -350,7 +408,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { try { $postInfo = $this->commentsCtx->posts->getPost($postInfo->id); } catch(RuntimeException $ex) { - return 410; + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); } $result = ['id' => $postInfo->id]; @@ -365,112 +423,97 @@ class CommentsRoutes implements RouteHandler, UrlSource { } #[HttpDelete('/comments/posts/([0-9]+)')] - public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array|string { try { $postInfo = $this->commentsCtx->posts->getPost($commentId); if($postInfo->deleted) - return 404; - } catch(RuntimeException $ex) { - return 404; - } + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); - try { $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); if($catInfo->locked) - return 403; + 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 404; + 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 403; + )) return self::error($response, 403, 'comments:delete-not-allowed', 'You are not allowed to delete this comment.'); $this->commentsCtx->posts->deletePost($postInfo); - return 204; + $response->statusCode = 204; + return ''; } #[HttpPost('/comments/posts/([0-9]+)/restore')] - public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array { if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY)) - return 403; + 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 400; - } catch(RuntimeException $ex) { - return 404; - } + return self::error($response, 400, 'comments:post-not-deleted', 'This comment is not currently deleted.'); - try { $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); if($catInfo->locked) - return 403; + 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 404; + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); } $this->commentsCtx->posts->restorePost($postInfo); - return 200; + return []; } #[HttpPost('/comments/posts/([0-9]+)/nuke')] - public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array { if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY)) - return 403; + 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 400; - } catch(RuntimeException $ex) { - return 404; - } + return self::error($response, 400, 'comments:post-not-deleted', 'This comment is not currently (soft-)deleted.'); - try { $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); if($catInfo->locked) - return 403; + 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 404; + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); } $this->commentsCtx->posts->nukePost($postInfo); - return 200; + return []; } #[HttpPost('/comments/posts/([0-9]+)/vote')] - public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array { if(!($request->content instanceof FormHttpContent)) - return 400; + 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 400; + return self::error($response, 400, 'comments:vote', 'Could not process vote.'); if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE)) - return 403; + 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 404; - } catch(RuntimeException $ex) { - return 404; - } + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); - try { $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); if($catInfo->locked) - return 403; + 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 404; + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); } $this->commentsCtx->votes->addVote( @@ -482,6 +525,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { $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, @@ -490,24 +534,20 @@ class CommentsRoutes implements RouteHandler, UrlSource { } #[HttpDelete('/comments/posts/([0-9]+)/vote')] - public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array { if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE)) - return 403; + 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 404; - } catch(RuntimeException $ex) { - return 404; - } + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); - try { $catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo); if($catInfo->locked) - return 403; + 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 404; + return self::error($response, 404, 'comments:post-not-found', 'Comment not found.'); } $this->commentsCtx->votes->removeVote( @@ -518,7 +558,6 @@ class CommentsRoutes implements RouteHandler, UrlSource { $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo); $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo); - $response->statusCode = 200; return [ 'vote' => $voteInfo->weight, 'positive' => $votes->positive,