diff --git a/assets/typescript/Comments.ts b/assets/typescript/Comments.ts index 6fd16a06..c2835821 100644 --- a/assets/typescript/Comments.ts +++ b/assets/typescript/Comments.ts @@ -1,3 +1,6 @@ +/// + + let globalCommentLock = false; function commentsLocked(): boolean @@ -18,13 +21,31 @@ function commentsFreeLock(): void globalCommentLock = false; } -interface CommentDeletionInfo { - comment_id: number; +interface CommentNotice { error: string; message: string; } -function commentDelete(ev: Event) +interface CommentDeletionInfo extends CommentNotice { + comment_id: number; +} + +interface CommentPostInfo extends CommentNotice { + comment_id: number; + category_id: number; + comment_text: string; + comment_html: string; + comment_created: Date; + comment_edited: Date | null; + comment_deleted: Date | null; + comment_reply_to: number; + comment_pinned: Date | null; + user_id: number; + username: string; + user_colour: number; +} + +function commentDelete(ev: Event): void { if (!checkUserPerm('comments', CommentPermission.Delete) || !commentsRequestLock()) return; @@ -37,13 +58,13 @@ function commentDelete(ev: Event) return; commentsFreeLock(); - var json: CommentDeletionInfo = JSON.parse(xhr.responseText) as CommentDeletionInfo, + let json: CommentDeletionInfo = JSON.parse(xhr.responseText) as CommentDeletionInfo, message = json.error || json.message; if (message) alert(message); else { - var elem = document.getElementById('comment-' + json.comment_id); + let elem = document.getElementById('comment-' + json.comment_id); if (elem) elem.parentNode.removeChild(elem); @@ -54,12 +75,265 @@ function commentDelete(ev: Event) xhr.send(); } -function commentsInit() { +function commentPostEventHandler(ev: Event): void +{ + const form: HTMLFormElement = ev.target as HTMLFormElement, + formData: FormData = ExtractFormData(form, true); + + commentPost( + formData, + info => commentPostSuccess(form, info), + message => commentPostFail + ); +} + +function commentPost(formData: FormData, onSuccess: (comment: CommentPostInfo) => void = null, onFail: (message: string) => void = null): void +{ + if (!commentsRequestLock()) + return; + + const xhr = new XMLHttpRequest(); + + xhr.addEventListener('readystatechange', () => { + if (xhr.readyState !== 4) + return; + + commentsFreeLock(); + + const json: CommentPostInfo = JSON.parse(xhr.responseText) as CommentPostInfo, + message: string = json.error || json.message; + + if (message && onFail) + onFail(message); + else if (!message && onSuccess) + onSuccess(json); + }); + + xhr.open('POST', '/comments.php?m=create'); + xhr.setRequestHeader('X-Misuzu-XHR', 'comments'); + xhr.send(formData); +} + +function commentPostSuccess(form: HTMLFormElement, comment: CommentPostInfo): void { + if (form.classList.contains('comment--reply')) + (form.parentNode.parentNode.querySelector('label.comment__action') as HTMLLabelElement).click(); + + //commentInsert(info, form); +} + +function commentPostFail(message: string): void { + alert(message); +} + +function commentsInit(): void { const commentDeletes: HTMLCollectionOf = document.getElementsByClassName('comment__action--delete') as HTMLCollectionOf; - for (var i = 0; i < commentDeletes.length; i++) { - commentDeletes[i].onclick = commentDelete; + for (let i = 0; i < commentDeletes.length; i++) { + commentDeletes[i].addEventListener('click', commentDelete); commentDeletes[i].dataset.href = commentDeletes[i].href; commentDeletes[i].href = 'javascript:void(0);'; } + + const commentInputs: HTMLCollectionOf = document.getElementsByClassName('comment__text--input') as HTMLCollectionOf; + + for (let i = 0; i < commentInputs.length; i++) { + commentInputs[i].addEventListener('keydown', ev => { + if (ev.keyCode === 13 && ev.ctrlKey && !ev.altKey && !ev.shiftKey) { + let form = commentInputs[i].form; + commentPost( + ExtractFormData(form, true), + info => commentPostSuccess(form, info), + message => commentPostFail + ); + } + }); + } +} + +function commentConstruct(comment: CommentPostInfo, layer: number = 0): HTMLElement { + const isReply = comment.comment_reply_to > 0; + + const commentElement: HTMLDivElement = document.createElement('div'); + commentElement.className = 'comment'; + commentElement.id = 'comment-' + comment.comment_id; + + // layer 2 + const commentContainer: HTMLDivElement = commentElement.appendChild(document.createElement('div')); + commentContainer.className = 'comment__container'; + + const commentReplies: HTMLDivElement = commentElement.appendChild(document.createElement('div')); + commentReplies.className = 'comment__replies comment__replies--indent-' + layer; + commentReplies.id = commentElement.id + '-replies'; + + // container + const commentAvatar: HTMLAnchorElement = commentContainer.appendChild(document.createElement('a')); + commentAvatar.className = 'avatar comment__avatar'; + commentAvatar.href = '/profile.php?u=' + comment.user_id; + commentAvatar.style.backgroundImage = `url('/profile.php?m=avatar&u=${comment.user_id}')`; + + const commentContent: HTMLDivElement = commentContainer.appendChild(document.createElement('div')); + commentContent.className = 'comment__content'; + + // content + const commentInfo = commentContent.appendChild(document.createElement('div')); + commentInfo.className = 'comment__info'; + + const commentText = commentContent.appendChild(document.createElement('div')); + commentText.className = 'comment__text'; + + if (comment.comment_html) + commentText.innerHTML = comment.comment_html; + else + commentText.textContent = comment.comment_text; + + const commentActions = commentContent.appendChild(document.createElement('div')); + commentActions.className = 'comment__actions'; + +} + +function commentInsert(comment, form): void +{ + var isReply = form.classList.contains('comment--reply'), + parent = isReply + ? form.parentNode + : form.parentNode.parentNode.getElementsByClassName('comments__listing')[0], + repliesIndent = isReply + ? (parseInt(parent.classList[1].substr(25)) + 1) + : 1; + + // info + var commentUser = document.createElement('a'); + commentUser.className = 'comment__user comment__user--link'; + commentUser.textContent = comment.username; + commentUser.href = '/profile?u=' + comment.user_id; + commentUser.style.color = comment.user_colour == null || (comment.user_colour & 0x40000000) > 0 + ? 'inherit' + : '#' + (comment.user_colour & 0xFFFFFF).toString(16); + commentInfo.appendChild(commentUser); + + var commentLink = document.createElement('a'); + commentLink.className = 'comment__link'; + commentLink.href = '#' + commentElement.id; + commentInfo.appendChild(commentLink); + + var commentTime = document.createElement('time'), + commentDate = new Date(comment.comment_created + 'Z'); + commentTime.className = 'comment__date'; + commentTime.title = commentDate.toLocaleString(); + commentTime.dateTime = commentDate.toISOString(); + commentTime.textContent = timeago().format(commentDate); + commentLink.appendChild(commentTime); + + // actions + if (typeof commentVote === 'function') { + var commentLike = document.createElement('a'); + commentLike.className = 'comment__action comment__action--link comment__action--like'; + commentLike.href = 'javascript:void(0);'; + commentLike.textContent = 'Like'; + commentLike.onclick = commentVote; + commentActions.appendChild(commentLike); + + var commentDislike = document.createElement('a'); + commentDislike.className = 'comment__action comment__action--link comment__action--dislike'; + commentDislike.href = 'javascript:void(0);'; + commentDislike.textContent = 'Dislike'; + commentDislike.onclick = commentVote; + commentActions.appendChild(commentDislike); + } + + // if we're executing this it's fairly obvious that we can reply, + // so no need to have a permission check on it here + var commentReply = document.createElement('label'); + commentReply.className = 'comment__action comment__action--link'; + commentReply.htmlFor = 'comment-reply-toggle-' + comment.comment_id; + commentReply.textContent = 'Reply'; + commentActions.appendChild(commentReply); + + // reply section + var commentReplyState = document.createElement('input'); + commentReplyState.id = commentReply.htmlFor; + commentReplyState.type = 'checkbox'; + commentReplyState.className = 'comment__reply-toggle'; + commentReplies.appendChild(commentReplyState); + + var commentReplyInput = document.createElement('form'); + commentReplyInput.id = 'comment-reply-' + comment.comment_id; + commentReplyInput.className = 'comment comment--input comment--reply'; + commentReplyInput.method = 'post'; + commentReplyInput.action = 'javascript:void(0);'; + commentReplyInput.onsubmit = commentPostEventHandler; + commentReplies.appendChild(commentReplyInput); + + // reply attributes + var replyCategory = document.createElement('input'); + replyCategory.name = 'comment[category]'; + replyCategory.value = comment.category_id; + replyCategory.type = 'hidden'; + commentReplyInput.appendChild(replyCategory); + + var replyCsrf = document.createElement('input'); + replyCsrf.name = 'csrf'; + replyCsrf.value = '{{ csrf_token("comments") }}'; + replyCsrf.type = 'hidden'; + commentReplyInput.appendChild(replyCsrf); + + var replyId = document.createElement('input'); + replyId.name = 'comment[reply]'; + replyId.value = comment.comment_id; + replyId.type = 'hidden'; + commentReplyInput.appendChild(replyId); + + var replyContainer = document.createElement('div'); + replyContainer.className = 'comment__container'; + commentReplyInput.appendChild(replyContainer); + + // reply container + var replyAvatar = document.createElement('div'); + replyAvatar.className = 'avatar comment__avatar'; + replyAvatar.style.backgroundImage = 'url(\'/profile.php?m=avatar&u={0}\')'.replace('{0}', comment.user_id); + replyContainer.appendChild(replyAvatar); + + var replyContent = document.createElement('div'); + replyContent.className = 'comment__content'; + replyContainer.appendChild(replyContent); + + // reply content + var replyInfo = document.createElement('div'); + replyInfo.className = 'comment__info'; + replyContent.appendChild(replyInfo); + + var replyUser = document.createElement('div'); + replyUser.className = 'comment__user'; + replyUser.textContent = comment.username; + replyUser.style.color = comment.user_colour == null || (comment.user_colour & 0x40000000) > 0 + ? 'inherit' + : '#' + (comment.user_colour & 0xFFFFFF).toString(16); + replyInfo.appendChild(replyUser); + + var replyText = document.createElement('textarea'); + replyText.className = 'comment__text input__textarea comment__text--input'; + replyText.name = 'comment[text]'; + replyText.placeholder = 'Share your extensive insights...'; + replyContent.appendChild(replyText); + + var replyActions = document.createElement('div'); + replyActions.className = 'comment__actions'; + replyContent.appendChild(replyActions); + + var replyButton = document.createElement('button'); + replyButton.className = 'input__button comment__action comment__action--button comment__action--post'; + replyButton.textContent = 'Reply'; + replyActions.appendChild(replyButton); + + if (isReply) + parent.appendChild(commentElement); + else + parent.insertBefore(commentElement, parent.firstElementChild); + + timeago().render(commentTime); + + var placeholder = document.getElementById('_no_comments_notice_' + comment.category_id); + + if (placeholder) + placeholder.parentNode.removeChild(placeholder); } diff --git a/assets/typescript/Common.ts b/assets/typescript/Common.ts new file mode 100644 index 00000000..82a67fe4 --- /dev/null +++ b/assets/typescript/Common.ts @@ -0,0 +1,3 @@ +interface Array { + find(predicate: (search: T) => boolean) : T; +} diff --git a/assets/typescript/FormUtilities.ts b/assets/typescript/FormUtilities.ts new file mode 100644 index 00000000..b07564f5 --- /dev/null +++ b/assets/typescript/FormUtilities.ts @@ -0,0 +1,48 @@ +function ExtractFormData(form: HTMLFormElement, resetSource: boolean = false): FormData +{ + const formData: FormData = new FormData; + + for (let i = 0; i < form.length; i++) { + let input: HTMLInputElement = form[i] as HTMLInputElement, + type = input.type.toLowerCase(), + isCheckbox = type === 'checkbox'; + + if (isCheckbox && !input.checked) + continue; + + formData.append(input.name, input.value || ''); + } + + if (resetSource) + ResetForm(form); + + return formData; +} + +interface FormHiddenDefault { + Name: string; + Value: string; +} + +function ResetForm(form: HTMLFormElement, defaults: FormHiddenDefault[] = []): void +{ + for (let i = 0; i < form.length; i++) { + let input: HTMLInputElement = form[i] as HTMLInputElement; + + switch (input.type.toLowerCase()) { + case 'checkbox': + input.checked = false; + break; + + case 'hidden': + let hiddenDefault: FormHiddenDefault = defaults.find(fhd => fhd.Name.toLowerCase() === input.name.toLowerCase()); + + if (hiddenDefault) + input.value = hiddenDefault.Value; + break; + + default: + input.value = ''; + } + } +} diff --git a/assets/typescript/misuzu.ts b/assets/typescript/misuzu.ts index c7da76c4..aae0d959 100644 --- a/assets/typescript/misuzu.ts +++ b/assets/typescript/misuzu.ts @@ -3,6 +3,7 @@ /// /// /// +/// declare const timeago: any; declare const hljs: any; diff --git a/templates/_layout/comments.twig b/templates/_layout/comments.twig index 94d32ef6..57ba1a3b 100644 --- a/templates/_layout/comments.twig +++ b/templates/_layout/comments.twig @@ -195,257 +195,17 @@ } } - if (typeof commentPost === 'function') { // can comment + if (typeof commentPostEventHandler === 'function') { // can comment var commentForms = document.getElementsByClassName('comment--input'); for (var i = 0; i < commentForms.length; i++) { commentForms[i].action = 'javascript:void(0);'; - commentForms[i].onsubmit = commentPost; + commentForms[i].onsubmit = commentPostEventHandler; } } }); - {% if perms.can_comment %} - - {% endif %} - {% if perms.can_vote %}