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

499 lines
19 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.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 tree
const map = new Map();
const roots = [];
comments.forEach(c => {
c.children = [];
map.set(c.id, c);
});
comments.forEach(c => {
if (c.parent_id && map.has(c.parent_id)) {
map.get(c.parent_id).children.push(c);
} else {
roots.push(c);
}
});
const subText = isSubscribed ? 'Subscribed' : 'Subscribe';
const subClass = isSubscribed ? 'active' : '';
let html = `
<div class="comments-header">
<h3>Comments (${comments.length})</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>
</div>
</div>
${currentUserId ? this.renderInput() : '<div class="login-placeholder"><a href="/login">Login</a> to comment</div>'}
<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) {
const isDeleted = comment.is_deleted;
const content = isDeleted ? '<span class="deleted-msg">[deleted]</span>' : this.renderCommentContent(comment.content);
const date = new Date(comment.created_at).toLocaleString();
// Admin buttons
let adminButtons = '';
if (this.isAdmin && !isDeleted) {
adminButtons = `
<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>
`;
}
return `
<div class="comment ${isDeleted ? 'deleted' : ''}" 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">
<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}">Reply</button>` : ''}
${adminButtons}
</div>
<div class="comment-content">${content}</div>
${comment.children.length > 0 ? `<div class="comment-children">${comment.children.map(c => this.renderComment(c, currentUserId)).join('')}</div>` : ''}
</div>
</div>
`;
}
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 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 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();
});
}
// 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.