Files
f0bm/public/s/js/comments.js

604 lines
24 KiB
JavaScript

class CommentSystem {
constructor() {
this.container = document.getElementById('comments-container');
this.itemId = this.container ? this.container.dataset.itemId : null;
this.user = this.container ? this.container.dataset.user : null; // logged in user?
this.sort = 'new';
if (this.itemId) {
this.init();
}
}
async init() {
await this.loadEmojis();
this.loadComments();
this.setupGlobalListeners();
}
async loadEmojis() {
try {
const res = await fetch('/api/v2/emojis');
const data = await res.json();
if (data.success) {
this.customEmojis = {};
data.emojis.forEach(e => {
this.customEmojis[e.name] = e.url;
});
console.log('Loaded Emojis:', this.customEmojis);
} else {
this.customEmojis = {};
}
} catch (e) {
console.error("Failed to load emojis", e);
this.customEmojis = {};
}
}
// ...
renderEmoji(match, name) {
// console.log('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
if (this.customEmojis && this.customEmojis[name]) {
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
}
return match;
}
async loadComments(scrollToId = null) {
if (!this.container) return;
if (!scrollToId) this.container.innerHTML = '<div class="loading">Loading comments...</div>';
try {
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
const data = await res.json();
if (data.success) {
this.isAdmin = data.is_admin || false;
this.isLocked = data.is_locked || false;
this.render(data.comments, data.user_id, data.is_subscribed);
// Priority: Explicit ID > Hash
if (scrollToId) {
this.scrollToComment(scrollToId);
} else if (window.location.hash && window.location.hash.startsWith('#c')) {
const hashId = window.location.hash.substring(2);
this.scrollToComment(hashId);
}
} else {
this.container.innerHTML = `<div class="error">Failed to load comments: ${data.message}</div>`;
}
} catch (e) {
console.error(e);
this.container.innerHTML = `<div class="error">Error loading comments: ${e.message}</div>`;
}
}
// ...
scrollToComment(id) {
// Allow DOM reflow
setTimeout(() => {
const el = document.getElementById('c' + id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.transition = "background-color 0.5s";
el.style.backgroundColor = "rgba(255, 255, 0, 0.2)";
setTimeout(() => el.style.backgroundColor = "", 2000);
}
}, 100);
}
render(comments, currentUserId, isSubscribed) {
// Build two-level tree: top-level comments + all replies at one level
const map = new Map();
const roots = [];
comments.forEach(c => {
c.replies = [];
c.replyTo = null; // Username being replied to (for @mentions)
map.set(c.id, c);
});
// Find root parent for any comment
const findRoot = (comment) => {
if (!comment.parent_id) return null;
let current = comment;
while (current.parent_id && map.has(current.parent_id)) {
current = map.get(current.parent_id);
}
return current;
};
comments.forEach(c => {
if (!c.parent_id) {
// Top-level comment
roots.push(c);
} else {
// It's a reply - find root and attach there
const root = findRoot(c);
if (root) {
// If replying to a non-root, capture the username for @mention
const directParent = map.get(c.parent_id);
if (directParent && directParent.id !== root.id) {
c.replyTo = directParent.username;
}
root.replies.push(c);
} else {
// Orphaned reply (parent deleted?) - show as root
roots.push(c);
}
}
});
// Sort replies by date (oldest first)
roots.forEach(r => {
if (r.replies && r.replies.length > 0) {
r.replies.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
}
});
const subText = isSubscribed ? 'Subscribed' : 'Subscribe';
const subClass = isSubscribed ? 'active' : '';
const lockIcon = this.isLocked ? '🔒' : '🔓';
const lockTitle = this.isLocked ? 'Unlock Thread' : 'Lock Thread';
const lockBtn = this.isAdmin ? `<button id="lock-thread-btn" title="${lockTitle}">${lockIcon}</button>` : '';
const lockNotice = this.isLocked ? '<div class="lock-notice">🔒 This thread is locked. New comments are disabled.</div>' : '';
// Determine what to show for input
let inputSection = '';
if (this.isLocked && !this.isAdmin) {
inputSection = '<div class="lock-notice">🔒 Comments are disabled on this thread.</div>';
} else if (currentUserId) {
inputSection = this.renderInput();
} else {
inputSection = '<div class="login-placeholder"><a href="/login">Login</a> to comment</div>';
}
let html = `
<div class="comments-header">
<h3>Comments (${comments.length}) ${this.isLocked ? '🔒' : ''}</h3>
<div class="comments-controls">
<select id="comment-sort">
<option value="old" ${this.sort === 'old' ? 'selected' : ''}>Oldest</option>
<option value="new" ${this.sort === 'new' ? 'selected' : ''}>Newest</option>
</select>
${currentUserId ? `<button id="subscribe-btn" class="${subClass}">${subText}</button>` : ''}
<button id="refresh-comments">Refresh</button>
${lockBtn}
</div>
</div>
${inputSection}
<div class="comments-list">
${roots.map(c => this.renderComment(c, currentUserId)).join('')}
</div>
`;
this.container.innerHTML = html;
this.bindEvents();
}
renderCommentContent(content) {
if (typeof marked === 'undefined') {
console.warn('Marked.js not loaded, falling back to plain text');
return this.escapeHtml(content).replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
}
try {
// 1. Escape HTML, but preserve > for blockquotes
let safe = content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const renderer = new marked.Renderer();
renderer.blockquote = function (quote) {
// If quote is an object (latest marked), extract text. Otherwise use it as string.
let text = (typeof quote === 'string') ? quote : (quote.text || '');
let cleanQuote = text.replace(/<p>|<\/p>|\n/g, '');
return `<span class="greentext">&gt; ${cleanQuote}</span><br>`;
};
let md = marked.parse(safe, {
breaks: true,
renderer: renderer
});
return md.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
} catch (e) {
console.error('Markdown error:', e);
return this.escapeHtml(content);
}
}
renderComment(comment, currentUserId, isReply = false) {
const isDeleted = comment.is_deleted;
const isPinned = comment.is_pinned;
// Add @mention prefix if this is a reply to a reply
let contentPrefix = '';
if (comment.replyTo) {
contentPrefix = `<span class="reply-mention">@${comment.replyTo}</span> `;
}
const content = isDeleted ? '<span class="deleted-msg">[deleted]</span>' : contentPrefix + this.renderCommentContent(comment.content);
const date = new Date(comment.created_at).toLocaleString();
// Admin buttons
let adminButtons = '';
if (this.isAdmin && !isDeleted) {
const pinIcon = isPinned ? '📌' : '📍';
const pinTitle = isPinned ? 'Unpin' : 'Pin';
adminButtons = `
<button class="admin-pin-btn" data-id="${comment.id}" title="${pinTitle}">${pinIcon}</button>
<button class="admin-edit-btn" data-id="${comment.id}" data-content="${this.escapeHtml(comment.content)}">✏️</button>
<button class="admin-delete-btn" data-id="${comment.id}">🗑️</button>
`;
}
const pinnedBadge = isPinned ? '<span class="pinned-badge">📌 Pinned</span>' : '';
const commentClass = isReply ? 'comment reply' : 'comment';
// Build replies HTML (only for root comments, max 1 level deep)
let repliesHtml = '';
if (!isReply && comment.replies && comment.replies.length > 0) {
repliesHtml = `<div class="comment-replies">${comment.replies.map(r => this.renderComment(r, currentUserId, true)).join('')}</div>`;
}
return `
<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}">
<div class="comment-avatar">
<img src="${comment.avatar ? `/t/${comment.avatar}.webp` : '/s/img/default.png'}" alt="av">
</div>
<div class="comment-body">
<div class="comment-meta">
${pinnedBadge}
<span class="comment-author">${comment.username || 'System'}</span>
<span class="comment-time">${date}</span>
<a href="#c${comment.id}" class="comment-permalink">#${comment.id}</a>
${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}">Reply</button>` : ''}
${adminButtons}
</div>
<div class="comment-content">${content}</div>
</div>
</div>
${repliesHtml}
`;
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
renderInput(parentId = null) {
return `
<div class="comment-input ${parentId ? 'reply-input' : 'main-input'}" ${parentId ? `data-parent="${parentId}"` : ''}>
<textarea placeholder="Write a comment..."></textarea>
<div class="input-actions">
<button class="submit-comment">Post</button>
${parentId ? '<button class="cancel-reply">Cancel</button>' : ''}
</div>
</div>
`;
}
bindEvents() {
// Sorting
const sortSelect = this.container.querySelector('#comment-sort');
if (sortSelect) {
sortSelect.addEventListener('change', (e) => {
this.sort = e.target.value;
this.loadComments();
});
}
// Posting
this.container.querySelectorAll('.submit-comment').forEach(btn => {
btn.addEventListener('click', (e) => this.handleSubmit(e));
});
// Delete
this.container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (!confirm('Delete this comment?')) return;
const id = e.target.dataset.id;
const res = await fetch(`/api/comments/${id}/delete`, { method: 'POST' });
const json = await res.json();
if (json.success) this.loadComments();
else alert('Failed to delete: ' + (json.message || 'Error'));
});
});
// Admin Delete
this.container.querySelectorAll('.admin-delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (!confirm('Admin: Delete this comment?')) return;
const id = e.target.dataset.id;
const res = await fetch(`/api/comments/${id}/delete`, { method: 'POST' });
const json = await res.json();
if (json.success) this.loadComments(id);
else alert('Failed to delete: ' + (json.message || 'Error'));
});
});
// Admin Pin
this.container.querySelectorAll('.admin-pin-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = e.target.dataset.id;
const res = await fetch(`/api/comments/${id}/pin`, { method: 'POST' });
const json = await res.json();
if (json.success) this.loadComments(id);
else alert('Failed to pin: ' + (json.message || 'Error'));
});
});
// Admin Edit
this.container.querySelectorAll('.admin-edit-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.target.dataset.id;
const currentContent = e.target.dataset.content;
const commentEl = document.getElementById('c' + id);
const contentEl = commentEl.querySelector('.comment-content');
// Replace content with textarea
const originalHtml = contentEl.innerHTML;
contentEl.innerHTML = `
<textarea class="edit-textarea">${currentContent}</textarea>
<div class="edit-actions">
<button class="save-edit-btn">Save</button>
<button class="cancel-edit-btn">Cancel</button>
</div>
`;
contentEl.querySelector('.cancel-edit-btn').addEventListener('click', () => {
contentEl.innerHTML = originalHtml;
});
contentEl.querySelector('.save-edit-btn').addEventListener('click', async () => {
const newContent = contentEl.querySelector('.edit-textarea').value;
if (!newContent.trim()) return alert('Cannot be empty');
const params = new URLSearchParams();
params.append('content', newContent);
const res = await fetch(`/api/comments/${id}/edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const json = await res.json();
if (json.success) {
this.loadComments(id);
} else {
alert('Failed to edit: ' + (json.message || 'Error'));
}
});
});
});
// Reply
this.container.querySelectorAll('.reply-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.target.dataset.id;
const username = e.target.dataset.username;
const commentEl = e.target.closest('.comment');
const isReplyingToReply = commentEl.classList.contains('reply');
const body = e.target.closest('.comment-body');
// Check if input already exists
if (body.querySelector('.reply-input')) return;
const div = document.createElement('div');
div.innerHTML = this.renderInput(id);
body.appendChild(div.firstElementChild);
// Bind new buttons
const newForm = body.querySelector('.reply-input');
newForm.querySelector('.submit-comment').addEventListener('click', (ev) => this.handleSubmit(ev));
newForm.querySelector('.cancel-reply').addEventListener('click', () => newForm.remove());
this.setupEmojiPicker(newForm);
});
});
// Main Input Emoji Picker
const mainInput = this.container.querySelector('.main-input');
if (mainInput) this.setupEmojiPicker(mainInput);
// Subscription
// Subscription
const subBtn = this.container.querySelector('#subscribe-btn');
if (subBtn) {
subBtn.addEventListener('click', async () => {
// Optimistic UI update
const isSubscribed = subBtn.textContent === 'Subscribed';
subBtn.textContent = 'Wait...';
try {
const res = await fetch(`/api/subscribe/${this.itemId}`, { method: 'POST' });
const json = await res.json();
if (json.success) {
subBtn.textContent = json.subscribed ? 'Subscribed' : 'Subscribe';
subBtn.classList.toggle('active', json.subscribed);
} else {
// Revert
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
alert('Failed to toggle subscription');
}
} catch (e) {
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
}
});
}
// Refresh
const refBtn = this.container.querySelector('#refresh-comments');
if (refBtn) {
refBtn.addEventListener('click', async () => {
this.loadComments();
});
}
// Lock Thread
const lockBtn = this.container.querySelector('#lock-thread-btn');
if (lockBtn) {
lockBtn.addEventListener('click', async () => {
const action = this.isLocked ? 'unlock' : 'lock';
if (!confirm(`Admin: ${action.toUpperCase()} this thread?`)) return;
try {
const res = await fetch(`/api/comments/${this.itemId}/lock`, { method: 'POST' });
const json = await res.json();
if (json.success) {
this.loadComments();
} else {
alert('Failed to lock/unlock: ' + (json.message || 'Error'));
}
} catch (e) {
alert('Error: ' + e);
}
});
}
// Permalinks
this.container.addEventListener('click', (e) => {
if (e.target.classList.contains('comment-permalink')) {
e.preventDefault();
const hash = e.target.getAttribute('href'); // #c123
const id = hash.substring(2);
// Update URL without reload/hashchange trigger if possible, or just pushState
history.pushState(null, null, hash);
// Manually scroll
this.scrollToComment(id);
}
});
}
async handleSubmit(e) {
const wrap = e.target.closest('.comment-input');
const text = wrap.querySelector('textarea').value;
const parentId = wrap.dataset.parent || null;
if (!text.trim()) return;
try {
const params = new URLSearchParams();
params.append('item_id', this.itemId);
if (parentId) params.append('parent_id', parentId);
params.append('content', text);
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const json = await res.json();
if (json.success) {
// Refresh comments or append locally
this.loadComments(json.comment.id);
} else {
alert('Error: ' + json.message);
}
} catch (err) {
console.error('Submit Error:', err);
alert('Failed to send comment: ' + err.toString());
}
}
setupGlobalListeners() {
window.addEventListener('hashchange', () => {
if (location.hash && location.hash.startsWith('#c')) {
const id = location.hash.substring(2);
this.scrollToComment(id);
}
});
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
setupEmojiPicker(container) {
const textarea = container.querySelector('textarea');
if (container.querySelector('.emoji-trigger')) return;
const trigger = document.createElement('button');
trigger.innerText = '☺';
trigger.className = 'emoji-trigger';
const actions = container.querySelector('.input-actions');
if (actions) {
actions.prepend(trigger);
trigger.addEventListener('click', (e) => {
e.preventDefault();
let picker = container.querySelector('.emoji-picker');
if (picker) {
picker.remove();
return;
}
picker = document.createElement('div');
picker.className = 'emoji-picker';
if (this.customEmojis && Object.keys(this.customEmojis).length > 0) {
Object.keys(this.customEmojis).forEach(name => {
const url = this.customEmojis[name];
const img = document.createElement('img');
img.src = url;
img.title = `:${name}:`;
img.onclick = (ev) => {
ev.stopPropagation();
textarea.value += ` :${name}: `;
textarea.focus();
};
picker.appendChild(img);
});
} else {
picker.innerHTML = '<div style="padding:5px;color:white;font-size:0.8em;">No emojis found</div>';
}
const closeHandler = (ev) => {
if (!picker.contains(ev.target) && ev.target !== trigger) {
picker.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
trigger.after(picker);
});
}
}
}
// Global instance or initialization
window.commentSystem = new CommentSystem();
// Re-init on navigation (if using SPA-like/pjax or custom f0ck.js navigation)
document.addEventListener('f0ck:contentLoaded', () => { // Assuming custom event or we hook into it
// f0ck.js probably replaces content. We need to re-init.
window.commentSystem = new CommentSystem();
});
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
// Looking at f0ck.js, it seems to just replace innerHTML.