516 lines
18 KiB
JavaScript
Executable File
516 lines
18 KiB
JavaScript
Executable File
/* 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);
|
|
initMedia(compose, 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();
|
|
|
|
// Upload pending images and voice note to this new beep
|
|
var pending = compose._beepPendingImages;
|
|
var pendingVoice = compose._beepPendingVoice;
|
|
var uploadDone = function () {
|
|
reloadFeed(feed, 'recent');
|
|
};
|
|
|
|
var tasks = [];
|
|
if (pending && pending.length && data.id) {
|
|
tasks.push(uploadPendingImages(data.id, pending, compose));
|
|
}
|
|
if (pendingVoice && data.id) {
|
|
tasks.push(uploadPendingVoice(data.id, pendingVoice, compose));
|
|
}
|
|
|
|
if (tasks.length) {
|
|
Promise.all(tasks).then(uploadDone);
|
|
}
|
|
})
|
|
.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;
|
|
}
|
|
|
|
|
|
/* ---------- Media upload (images) ---------- */
|
|
function initMedia(compose, feed) {
|
|
var input = compose.querySelector('[data-beep-media-input]');
|
|
var preview = compose.querySelector('[data-beep-media-preview]');
|
|
var counter = compose.querySelector('[data-beep-media-count]');
|
|
var voiceInput = compose.querySelector('[data-beep-voice-input]');
|
|
var voicePreview = compose.querySelector('[data-beep-voice-preview]');
|
|
if (!input && !voiceInput) return;
|
|
|
|
var pendingImages = [];
|
|
var pendingVoice = null; // { file, id }
|
|
|
|
// Voice note input
|
|
if (voiceInput) {
|
|
voiceInput.addEventListener('change', function (e) {
|
|
var files = e.target.files;
|
|
if (!files || !files.length) return;
|
|
var file = files[0];
|
|
if (file.size > 20 * 1024 * 1024) {
|
|
window.alert('Voice note must be under 20 MB.');
|
|
voiceInput.value = '';
|
|
return;
|
|
}
|
|
var accepted = ['audio/mpeg','audio/ogg','audio/wav','audio/webm','audio/mp4','audio/x-m4a'];
|
|
if (accepted.indexOf(file.type) === -1) {
|
|
window.alert('Only MP3, OGG, WAV, WebM, and M4A audio are allowed.');
|
|
voiceInput.value = '';
|
|
return;
|
|
}
|
|
if (pendingVoice) {
|
|
window.alert('Only one voice note per beep.');
|
|
voiceInput.value = '';
|
|
return;
|
|
}
|
|
var reader = new FileReader();
|
|
reader.onload = function (ev) {
|
|
var id = 'voice-' + Date.now();
|
|
pendingVoice = { file: file, id: id };
|
|
voicePreview.innerHTML = '<div class="beep-voice-preview-card"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08C16.39 17.43 19 14.53 19 11h-2z"/></svg><span>Voice note</span><button class="beep-voice-remove" data-beep-voice-remove type="button" title="Remove">×</button></div>';
|
|
voicePreview.hidden = false;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
voiceInput.value = '';
|
|
});
|
|
}
|
|
|
|
// Voice remove button
|
|
if (voicePreview) {
|
|
voicePreview.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('[data-beep-voice-remove]');
|
|
if (!btn) return;
|
|
pendingVoice = null;
|
|
voicePreview.innerHTML = '';
|
|
voicePreview.hidden = true;
|
|
});
|
|
}
|
|
|
|
input.addEventListener('change', function (e) {
|
|
var files = Array.prototype.slice.call(e.target.files);
|
|
if (!files.length) return;
|
|
var room = 4 - pendingImages.length;
|
|
if (room <= 0) { input.value = ''; return; }
|
|
files = files.slice(0, room);
|
|
files.forEach(function (file) {
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
window.alert('Image must be under 10 MB: ' + file.name);
|
|
return;
|
|
}
|
|
var reader = new FileReader();
|
|
reader.onload = function (ev) {
|
|
var url = ev.target.result;
|
|
var id = 'pending-' + Date.now() + '-' + Math.random().toString(36).substr(2, 6);
|
|
pendingImages.push({ file: file, previewUrl: url, id: id });
|
|
renderPreviews();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
input.value = '';
|
|
});
|
|
|
|
function renderPreviews() {
|
|
if (!pendingImages.length) {
|
|
preview.innerHTML = '';
|
|
preview.hidden = true;
|
|
if (counter) counter.hidden = true;
|
|
return;
|
|
}
|
|
preview.hidden = false;
|
|
if (counter) {
|
|
counter.textContent = pendingImages.length + '/4';
|
|
counter.hidden = false;
|
|
}
|
|
preview.innerHTML = pendingImages.map(function (img) {
|
|
return '<div class="beep-media-preview-item" data-preview-id="' + img.id + '">' +
|
|
'<img src="' + img.previewUrl + '">' +
|
|
'<button class="beep-media-remove" type="button" title="Remove" data-remove="' + img.id + '">×</button>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
preview.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('[data-remove]');
|
|
if (!btn) return;
|
|
var id = btn.dataset.remove;
|
|
pendingImages = pendingImages.filter(function (img) { return img.id !== id; });
|
|
renderPreviews();
|
|
});
|
|
|
|
compose._beepPendingImages = pendingImages;
|
|
compose._beepPendingVoice = pendingVoice;
|
|
}
|
|
|
|
function uploadPendingImages(postId, pendingImages, compose) {
|
|
if (!pendingImages || !pendingImages.length) return Promise.resolve();
|
|
var promises = pendingImages.map(function (img) {
|
|
var formData = new FormData();
|
|
formData.append('file', img.file);
|
|
return fetch(config.restUrl + 'media/' + postId, {
|
|
method: 'POST',
|
|
headers: { 'X-WP-Nonce': config.nonce },
|
|
credentials: 'same-origin',
|
|
body: formData
|
|
})
|
|
.then(handleResponse)
|
|
.catch(function (err) {
|
|
console.warn('Image upload failed:', img.file.name, err);
|
|
});
|
|
});
|
|
return Promise.all(promises).then(function () {
|
|
pendingImages.length = 0;
|
|
if (compose) {
|
|
var preview = compose.querySelector('[data-beep-media-preview]');
|
|
if (preview) { preview.innerHTML = ''; preview.hidden = true; }
|
|
var counter = compose.querySelector('[data-beep-media-count]');
|
|
if (counter) counter.hidden = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
function uploadPendingVoice(postId, pendingVoice, compose) {
|
|
if (!pendingVoice) return Promise.resolve();
|
|
var formData = new FormData();
|
|
formData.append('file', pendingVoice.file);
|
|
return fetch(config.restUrl + 'voice/' + postId, {
|
|
method: 'POST',
|
|
headers: { 'X-WP-Nonce': config.nonce },
|
|
credentials: 'same-origin',
|
|
body: formData
|
|
})
|
|
.then(handleResponse)
|
|
.then(function () {
|
|
pendingVoice = null;
|
|
if (compose) {
|
|
var vp = compose.querySelector('[data-beep-voice-preview]');
|
|
if (vp) { vp.innerHTML = ''; vp.hidden = true; }
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
console.warn('Voice upload failed:', err);
|
|
});
|
|
}
|
|
|
|
function handleResponse(res) {
|
|
if (!res.ok) return res.json().then(function (j) { throw j; }, function () { throw res; });
|
|
return res.json();
|
|
}
|
|
})();
|