diff --git a/js/blogtini.js b/js/blogtini.js index 28f7404..88095d5 100644 --- a/js/blogtini.js +++ b/js/blogtini.js @@ -744,8 +744,6 @@ function add_interactivity() { }) }) - import('./staticman.js') - search_setup(STORAGE.docs, cfg) } diff --git a/js/staticman.js b/js/staticman.js deleted file mode 100644 index da8eea1..0000000 --- a/js/staticman.js +++ /dev/null @@ -1,127 +0,0 @@ -import { cfg } from './blogtini.js' - -function find_form() { - return document.querySelector('bt-post-full')?.shadowRoot?.querySelector('bt-comments')?.shadowRoot?.querySelector('.new-comment') -} - -function comment_setup() { - const form = find_form() - if (!form) { - // eslint-disable-next-line no-console - console.log('comment form not found') - return - } - - form.querySelector('#comment-form-submit').addEventListener('click', () => { - form.classList.add('loading') - form.querySelector('#comment-form-submit').classList.add('hidden') // hide "submit" - form.querySelector('#comment-form-submitted').classList.remove('hidden') // show "submitted" - - // Construct form action URL form JS to avoid spam - const { api } = cfg.staticman - const gitProvider = cfg.git_provider - const username = cfg.user - const { repo } = cfg - const { branch } = cfg.staticman - const url = ['https:/', api, 'v3/entry', gitProvider, username, repo, branch, 'comments'].join('/') - - // Convert form fields to a JSON-friendly string - const formObj = Object.fromEntries(new FormData(form)) - const xhrObj = { fields: {}, options: {} } - Object.entries(formObj).forEach(([key, value]) => { - const a = key.indexOf('[') - const b = key.indexOf('reCaptcha') - if (a === -1) { // key = "g-recaptcha-response" - xhrObj[key] = value - } else if (a === 6 || (a === 7 && b === -1)) { // key = "fields[*]", "options[*]" - xhrObj[key.slice(0, a)][key.slice(a + 1, -1)] = value - } else { // key = "options[reCaptcha][*]" - // define xhrObj.options.reCaptcha if it doesn't exist - xhrObj.options.reCaptcha = xhrObj.options.reCaptcha || {} - xhrObj.options.reCaptcha[key.slice(b + 11, -1)] = value - } - }) - const formData = JSON.stringify(xhrObj) // some API don't accept FormData objects - - const xhr = new XMLHttpRequest() - xhr.open('POST', url) - xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8') - xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') - xhr.onreadystatechange = function () { - if (xhr.readyState === XMLHttpRequest.DONE) { - const { status } = xhr - if (status >= 200 && status < 400) { - // eslint-disable-next-line no-use-before-define - showAlert(form, 'success') - // eslint-disable-next-line no-use-before-define - setTimeout(() => { clearForm() }, 3000) // display success message for 3s - form.classList.remove('loading') - } else { - // eslint-disable-next-line no-use-before-define - showAlert(form, 'failed') - form.classList.remove('loading') - } - } - } - xhr.send(formData) - }) - - // record reply target when one of the "reply" buttons is pressed - for (const e of document.querySelector('bt-post-full').shadowRoot.querySelector('bt-comments').shadowRoot.querySelectorAll('bt-comment')) { - e.shadowRoot.querySelector('.comment-reply-btn').addEventListener('click', (evt) => { - // eslint-disable-next-line no-use-before-define - resetReplyTarget() - let cmt = evt.currentTarget - while (!cmt.matches('.comment')) // find the comment containing the clicked "reply" button - cmt = cmt.parentNode - form.querySelector('input[name="fields[replyID]"]').value = cmt.getAttribute('id') - const replyName = cmt.querySelector('.comment-author').innerText - - // display reply name - form.querySelector('.reply-notice').classList.remove('hidden') - form.querySelector('.reply-name').innerText = replyName - }) - } - - // handle removal of reply target when '×' is pressed - // eslint-disable-next-line no-use-before-define - form.querySelector('.reply-close-btn').addEventListener('click', resetReplyTarget) - - // clear form when reset button is clicked - // eslint-disable-next-line no-use-before-define - form.querySelector('#comment-form-reset').addEventListener('click', clearForm) -} - - -function showAlert(form, msg) { - if (msg === 'success') { - form.querySelector('.submit-success').classList.remove('hidden') // show submit success message - form.querySelector('.submit-failed').classList.add('hidden') // hide submit failed message - } else { - form.querySelector('.submit-success').classList.add('hidden') // hide submit success message - form.querySelector('.submit-failed').classList.remove('hidden') // show submit failed message - } - form.querySelector('#comment-form-submit').classList.remove('hidden') // show "submit" - form.querySelector('#comment-form-submitted').classList.add('hidden') // hide "submitted" -} - -function resetReplyTarget() { - const form = find_form() - form.querySelector('.reply-notice .reply-name').innerText = '' - form.querySelector('.reply-notice').classList.add('hidden') // hide reply target display - // empty all hidden fields whose name starts from "reply" - // eslint-disable-next-line no-return-assign - Array.from(form.elements).filter((e) => e.name.indexOf('fields[reply') === 0).forEach((e) => e.value = '') -} - -// empty all text & hidden fields but not options -function clearForm() { - resetReplyTarget() - const form = find_form() - form.querySelector('.submit-success').classList.add('hidden') // hide submission status - form.querySelector('.submit-failed').classList.add('hidden') // hide submission status -} - -comment_setup() - -export default comment_setup diff --git a/theme/future-imperfect/bt-comment.js b/theme/future-imperfect/bt-comment.js index d510e89..0cbf7b8 100644 --- a/theme/future-imperfect/bt-comment.js +++ b/theme/future-imperfect/bt-comment.js @@ -36,7 +36,7 @@ customElements.define('bt-comment', class extends LitElement { - Reply + Reply
@@ -44,10 +44,18 @@ customElements.define('bt-comment', class extends LitElement {
- ` } + // Dispatches a custom event (to parent component) when the reply button is clicked + reply_clicked() { + this.dispatchEvent(new CustomEvent('bt-comment-reply-clicked', { + detail: { id: this.id, author: this.name }, // Pass up event details + bubbles: true, // Make sure the event bubbles up to the parent + composed: true, // Allow the event to cross shadow DOM boundaries + })) + } + static get styles() { return [ css_post(), // xxxcc figure these out diff --git a/theme/future-imperfect/bt-comments.js b/theme/future-imperfect/bt-comments.js index e13f765..c11a514 100644 --- a/theme/future-imperfect/bt-comments.js +++ b/theme/future-imperfect/bt-comments.js @@ -1,5 +1,7 @@ import { LitElement, html, css } from 'https://esm.ext.archive.org/lit@3.2.1' -import { fetcher, state, cssify } from '../../js/blogtini.js' +import { + fetcher, state, cssify, cfg, +} from '../../js/blogtini.js' import { css_dark, css_headers, css_buttons, css_post, css_forms, } from './index.js' @@ -14,19 +16,11 @@ customElements.define('bt-comments', class extends LitElement { } render() { - if (typeof this.comments === 'undefined') { - // use a default base in case url is relative (pathname) only - this.comments_get(this.entryid).then( - (comments) => { - this.comments = comments - }, - ) - } + if (typeof this.comments === 'undefined') + this.comments_get() - if (this.comments && this.comments.length) { + if (this.comments && this.comments.length) this.comments_insert() - import('../../js/staticman.js') // xxxxxx move into this class - } return html` @@ -36,7 +30,7 @@ customElements.define('bt-comments', class extends LitElement {
@@ -52,20 +46,53 @@ customElements.define('bt-comments', class extends LitElement { - + - +

Comments

- ${this.comments && this.comments.length ? '' : '

Nothing yet.

'} + ${this.comments && this.comments.length ? '' : html`

Nothing yet.

`}
` } + /** + * handles evnets from children + * @param {*} event + */ + events_handler(event) { + if (event.type === 'bt-comment-reply-clicked') { + // console.log(this, `comment reply event: ${event.detail.id} by ${event.detail.author}`) + + const form = this.find_form() + this.resetReplyTarget() + form.querySelector('input[name="fields[replyID]"]').value = event.detail.id + + // display reply name + form.querySelector('.reply-notice').classList.remove('hidden') + form.querySelector('.reply-name').innerText = event.detail.author + } + } + + /** + * Dynamically attaches event listener for all bt-comment events + */ + connectedCallback() { + super.connectedCallback() + this.addEventListener('bt-comment-reply-clicked', this.events_handler) + } + + /** + * Cleans up event listener when the component is removed + */ + disconnectedCallback() { + super.disconnectedCallback() + this.removeEventListener('bt-comment-reply-clicked', this.events_handler) + } /** * Cleverly use DOM to add (potentially nested) comment elements into a div @@ -111,20 +138,19 @@ customElements.define('bt-comments', class extends LitElement { } - /** - * @param {string} path - */ - async comments_get(path) { + async comments_get() { let posts_with_comments try { posts_with_comments = (await fetcher(`${state.top_dir}comments/index.txt`))?.split('\n') /* eslint-disable-next-line no-empty */ // deno-lint-ignore no-empty } catch {} - if (!posts_with_comments?.includes(path)) - return [] + if (!posts_with_comments?.includes(this.entryid)) { + this.comments = [] + return + } // oldest comments (or oldest in a thread) first - return (await fetcher(`${state.top_dir}comments/${path}/index.json`))?.filter((e) => Object.keys(e).length).sort((a, b) => a.date < b.date).map((e) => { + this.comments = (await fetcher(`${state.top_dir}comments/${this.entryid}/index.json`))?.filter((e) => Object.keys(e).length).sort((a, b) => a.date < b.date).map((e) => { // delete any unused keys in each comment hashmap for (const [k, v] of Object.entries(e)) { if (v === '' || v === undefined || v === null) @@ -136,6 +162,91 @@ customElements.define('bt-comments', class extends LitElement { }) } + find_form() { + return this.shadowRoot.querySelector('.new-comment') + } + + submitted() { + const form = this.find_form() + form.classList.add('loading') + form.querySelector('#comment-form-submit').classList.add('hidden') // hide "submit" + form.querySelector('#comment-form-submitted').classList.remove('hidden') // show "submitted" + + // Construct form action URL form JS to avoid spam + const { api } = cfg.staticman + const gitProvider = cfg.git_provider + const username = cfg.user + const { repo } = cfg + const { branch } = cfg.staticman + const url = ['https:/', api, 'v3/entry', gitProvider, username, repo, branch, 'comments'].join('/') + + // Convert form fields to a JSON-friendly string + const formObj = Object.fromEntries(new FormData(form)) + const xhrObj = { fields: {}, options: {} } + Object.entries(formObj).forEach(([key, value]) => { + const a = key.indexOf('[') + const b = key.indexOf('reCaptcha') + if (a === -1) { // key = "g-recaptcha-response" + xhrObj[key] = value + } else if (a === 6 || (a === 7 && b === -1)) { // key = "fields[*]", "options[*]" + xhrObj[key.slice(0, a)][key.slice(a + 1, -1)] = value + } else { // key = "options[reCaptcha][*]" + // define xhrObj.options.reCaptcha if it doesn't exist + xhrObj.options.reCaptcha = xhrObj.options.reCaptcha || {} + xhrObj.options.reCaptcha[key.slice(b + 11, -1)] = value + } + }) + const formData = JSON.stringify(xhrObj) // some API don't accept FormData objects + + const xhr = new XMLHttpRequest() + xhr.open('POST', url) + xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8') + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') + xhr.onreadystatechange = () => { + if (xhr.readyState === XMLHttpRequest.DONE) { + const { status } = xhr + if (status >= 200 && status < 400) { + this.showAlert(form, 'success') + setTimeout(() => { this.clearForm() }, 3000) // display success message for 3s + form.classList.remove('loading') + } else { + this.showAlert(form, 'failed') + form.classList.remove('loading') + } + } + } + xhr.send(formData) + } + + // eslint-disable-next-line class-methods-use-this + showAlert(form, msg) { + if (msg === 'success') { + form.querySelector('.submit-success').classList.remove('hidden') // show submit success message + form.querySelector('.submit-failed').classList.add('hidden') // hide submit failed message + } else { + form.querySelector('.submit-success').classList.add('hidden') // hide submit success message + form.querySelector('.submit-failed').classList.remove('hidden') // show submit failed message + } + form.querySelector('#comment-form-submit').classList.remove('hidden') // show "submit" + form.querySelector('#comment-form-submitted').classList.add('hidden') // hide "submitted" + } + + resetReplyTarget() { + const form = this.find_form() + form.querySelector('.reply-notice .reply-name').innerText = '' + form.querySelector('.reply-notice').classList.add('hidden') // hide reply target display + // empty all hidden fields whose name starts from "reply" + // eslint-disable-next-line no-return-assign + Array.from(form.elements).filter((e) => e.name.indexOf('fields[reply') === 0).forEach((e) => e.value = '') + } + + // empty all text & hidden fields but not options + clearForm() { + this.resetReplyTarget() + const form = this.find_form() + form.querySelector('.submit-success').classList.add('hidden') // hide submission status + form.querySelector('.submit-failed').classList.add('hidden') // hide submission status + } static get styles() { return [ diff --git a/theme/future-imperfect/bt-post-full.js b/theme/future-imperfect/bt-post-full.js index 2d51a7b..b1956c6 100644 --- a/theme/future-imperfect/bt-post-full.js +++ b/theme/future-imperfect/bt-post-full.js @@ -23,6 +23,7 @@ customElements.define('bt-post-full', class extends LitElement { const post = url2post(this.url) const body = markdown_to_html(post.body_raw) + // use a default base in case url is relative (pathname) only const comments_entryid = new URL(post.url, 'https://blogtini.com').pathname.replace(/^\/+/, '').replace(/\/+$/, '') // xxx const socnet_share = share_buttons(post)