Initial commit: Beep WordPress plugin

- Custom post type with likes, replies, moderation, shortcodes
- Beep icon and logo assets
- CSS and JS for frontend
This commit is contained in:
Krystie
2026-05-18 02:22:57 -07:00
commit ccead50116
17 changed files with 2181 additions and 0 deletions
Executable
+332
View File
@@ -0,0 +1,332 @@
/* Beep — Twitter-style microblog frontend */
(function () {
'use strict';
var config = window.BeepConfig || {};
var limit = (config.charLimit || 280) | 0;
document.addEventListener('DOMContentLoaded', init);
function init() {
var feeds = document.querySelectorAll('[data-beep-feed]');
Array.prototype.forEach.call(feeds, initFeed);
}
function initFeed(feed) {
var compose = feed.querySelector('[data-beep-compose]');
if (compose) initCompose(compose, feed);
initSortTabs(feed);
feed.addEventListener('click', onClick);
feed.addEventListener('input', onInput);
feed.addEventListener('keydown', onKeydown);
}
/* ---------- Composer ---------- */
function initCompose(compose, feed) {
var input = compose.querySelector('[data-beep-input]');
var counter = compose.querySelector('[data-beep-counter]');
var button = compose.querySelector('[data-beep-submit]');
if (!input || !button) return;
function refresh() {
autosize(input);
var remaining = limit - countChars(input.value);
counter.textContent = remaining;
counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0);
counter.classList.toggle('beep-counter-bad', remaining < 0);
button.disabled = input.value.trim() === '' || remaining < 0;
}
input.addEventListener('input', refresh);
refresh();
button.addEventListener('click', function () {
var content = input.value.trim();
if (!content) return;
button.disabled = true;
fetch(config.restUrl + 'post', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
credentials: 'same-origin',
body: JSON.stringify({ content: content })
})
.then(handleResponse)
.then(function (data) {
var list = feed.querySelector('[data-beep-list]');
var empty = list.querySelector('.beep-empty');
if (empty) empty.remove();
var tmp = document.createElement('div');
tmp.innerHTML = data.html;
var node = tmp.firstElementChild;
if (node) {
node.classList.add('beep-new');
list.insertBefore(node, list.firstChild);
}
input.value = '';
refresh();
})
.catch(function () {
window.alert('Could not post. Please try again.');
button.disabled = false;
});
});
}
/* ---------- Sort tabs ---------- */
function initSortTabs(feed) {
var tabs = feed.querySelector('[data-beep-tabs]');
if (!tabs) return;
tabs.addEventListener('click', function (e) {
var btn = e.target.closest('[data-beep-sort]');
if (!btn) return;
e.preventDefault();
var sort = btn.dataset.beepSort;
tabs.querySelectorAll('[data-beep-sort]').forEach(function (b) {
b.classList.toggle('is-active', b === btn);
});
reloadFeed(feed, sort);
});
}
function reloadFeed(feed, sort) {
var list = feed.querySelector('[data-beep-list]');
if (!list) return;
list.classList.add('beep-loading');
fetch(config.restUrl + 'feed?sort=' + encodeURIComponent(sort), {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': config.nonce }
})
.then(handleResponse)
.then(function (data) {
list.innerHTML = data.html || '<div class="beep-empty">No beeps yet.</div>';
list.classList.remove('beep-loading');
})
.catch(function () {
list.classList.remove('beep-loading');
});
}
/* ---------- Click delegation ---------- */
function onClick(e) {
var t = e.target;
var likePost = t.closest('[data-beep-like-post]');
if (likePost) { e.preventDefault(); if (requireLogin()) handleLike(likePost, 'like/' + likePost.dataset.beepLikePost); return; }
var likeReply = t.closest('[data-beep-like-reply]');
if (likeReply) { e.preventDefault(); if (requireLogin()) handleLike(likeReply, 'reply-like/' + likeReply.dataset.beepLikeReply); return; }
var replyToggle = t.closest('[data-beep-toggle-reply]');
if (replyToggle) {
e.preventDefault();
if (!requireLogin()) return;
// The reply form is the sibling .beep-reply-form belonging to the post or reply
// that this toggle button is inside.
var container = replyToggle.closest('[data-beep-post], [data-beep-reply]');
if (!container) return;
var form = container.querySelector(':scope > .beep-post-body > [data-beep-reply-form], :scope > .beep-reply-body > [data-beep-reply-form]');
if (!form) return;
form.hidden = !form.hidden;
if (!form.hidden) {
var ta = form.querySelector('textarea');
if (ta) ta.focus();
}
return;
}
var replyBtn = t.closest('[data-beep-reply-submit]');
if (replyBtn) { e.preventDefault(); handleReplySubmit(replyBtn); return; }
var shareBtn = t.closest('[data-beep-share]');
if (shareBtn) { e.preventDefault(); handleShare(shareBtn); return; }
var delPost = t.closest('[data-beep-delete]');
if (delPost) {
e.preventDefault();
if (!window.confirm('Delete this beep?')) return;
handleDelete('beep/' + delPost.dataset.beepDelete, '[data-beep-post="' + delPost.dataset.beepDelete + '"]');
return;
}
var delReply = t.closest('[data-beep-delete-reply]');
if (delReply) {
e.preventDefault();
if (!window.confirm('Delete this reply?')) return;
handleDelete('reply/' + delReply.dataset.beepDeleteReply, '[data-beep-reply="' + delReply.dataset.beepDeleteReply + '"]');
return;
}
}
function onInput(e) {
var input = e.target.closest('.beep-reply-input, .beep-compose-input');
if (!input) return;
autosize(input);
var counter = input.parentElement.querySelector('.beep-char-counter');
var submit = input.parentElement.querySelector('.beep-button');
if (counter) {
var remaining = limit - countChars(input.value);
counter.textContent = remaining;
counter.classList.toggle('beep-counter-warn', remaining < 20 && remaining >= 0);
counter.classList.toggle('beep-counter-bad', remaining < 0);
if (submit) submit.disabled = input.value.trim() === '' || remaining < 0;
}
}
function onKeydown(e) {
if (!((e.metaKey || e.ctrlKey) && e.key === 'Enter')) return;
var input = e.target.closest('textarea');
if (!input) return;
var wrap = input.closest('.beep-compose, .beep-reply-form');
if (!wrap) return;
var btn = wrap.querySelector('button');
if (btn && !btn.disabled) btn.click();
}
/* ---------- Like (works for both posts and replies) ---------- */
function handleLike(btn, urlFragment) {
var countEl = btn.querySelector('[data-beep-like-count]');
var was = btn.classList.contains('is-liked');
btn.classList.toggle('is-liked');
if (!was) {
btn.classList.add('beep-just-liked');
setTimeout(function () { btn.classList.remove('beep-just-liked'); }, 600);
}
if (countEl) {
var current = parseInt(countEl.textContent, 10) || 0;
var optimistic = current + (was ? -1 : 1);
countEl.textContent = optimistic > 0 ? optimistic : '';
}
fetch(config.restUrl + urlFragment, {
method: 'POST',
headers: { 'X-WP-Nonce': config.nonce },
credentials: 'same-origin'
})
.then(handleResponse)
.then(function (data) {
if (countEl) countEl.textContent = data.count > 0 ? data.count : '';
btn.classList.toggle('is-liked', !!data.liked);
})
.catch(function () {
btn.classList.toggle('is-liked', was);
});
}
/* ---------- Reply submit (top-level or nested) ---------- */
function handleReplySubmit(btn) {
var postId = btn.dataset.beepReplySubmit;
var parent = btn.dataset.beepReplyParent || '0';
var form = btn.closest('[data-beep-reply-form]');
var input = form && form.querySelector('textarea');
if (!input) return;
var content = input.value.trim();
if (!content) return;
btn.disabled = true;
fetch(config.restUrl + 'reply/' + postId, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce },
credentials: 'same-origin',
body: JSON.stringify({ content: content, parent: parseInt(parent, 10) || 0 })
})
.then(handleResponse)
.then(function (data) {
if (data.pending) {
form.innerHTML = '<div class="beep-pending">Reply submitted, awaiting moderation.</div>';
return;
}
// Where do we insert?
// If parent === 0, append to the post's top-level [data-beep-replies] list.
// Otherwise append (creating if needed) to the parent reply's nested list.
var post = btn.closest('[data-beep-post]');
var targetList;
if ((parseInt(parent, 10) || 0) === 0) {
targetList = post && post.querySelector(':scope > .beep-post-body > [data-beep-replies]');
} else {
var parentReply = post && post.querySelector('[data-beep-reply="' + parent + '"]');
if (parentReply) {
targetList = parentReply.querySelector(':scope > .beep-reply-body > .beep-replies-nested');
if (!targetList) {
targetList = document.createElement('div');
targetList.className = 'beep-replies beep-replies-nested';
targetList.setAttribute('data-beep-replies', '');
parentReply.querySelector('.beep-reply-body').appendChild(targetList);
}
}
}
if (targetList) {
var tmp = document.createElement('div');
tmp.innerHTML = data.html;
var node = tmp.firstElementChild;
if (node) {
node.classList.add('beep-new');
targetList.appendChild(node);
}
}
// Increment top-level reply counter on the post.
var countEl = post && post.querySelector('[data-beep-reply-count]');
if (countEl) {
countEl.textContent = (parseInt(countEl.textContent, 10) || 0) + 1;
}
input.value = '';
autosize(input);
form.hidden = true;
})
.catch(function () {
window.alert('Could not post reply. Please try again.');
})
.finally(function () { btn.disabled = false; });
}
/* ---------- Share (copy link) ---------- */
function handleShare(btn) {
var url = btn.dataset.beepShare;
if (!url || !navigator.clipboard) return;
navigator.clipboard.writeText(url).then(function () {
var tip = document.createElement('span');
tip.className = 'beep-tip';
tip.textContent = 'Copied';
btn.style.position = 'relative';
btn.appendChild(tip);
setTimeout(function () { tip.remove(); }, 1500);
});
}
/* ---------- Delete ---------- */
function handleDelete(pathFragment, selector) {
fetch(config.restUrl + pathFragment, {
method: 'DELETE',
headers: { 'X-WP-Nonce': config.nonce },
credentials: 'same-origin'
})
.then(handleResponse)
.then(function () {
var el = document.querySelector(selector);
if (el) el.remove();
})
.catch(function () {
window.alert('Could not delete.');
});
}
/* ---------- Helpers ---------- */
function requireLogin() {
if (config.isLoggedIn) return true;
if (config.loginUrl) window.location.href = config.loginUrl;
return false;
}
function autosize(el) {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
function countChars(s) {
return Array.from(s).length;
}
function handleResponse(res) {
if (!res.ok) return res.json().then(function (j) { throw j; }, function () { throw res; });
return res.json();
}
})();