647 lines
25 KiB
JavaScript
647 lines
25 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 guest, hide completely and don't fetch
|
|
if (!this.user) {
|
|
this.container.innerHTML = '';
|
|
this.container.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Render skeleton (Result: Layout visible immediately)
|
|
if (!scrollToId) {
|
|
this.container.style.opacity = '1'; // Ensure container is visible
|
|
this.container.style.transition = ''; // Reset
|
|
|
|
// Assume defaults for skeleton
|
|
this.render([], this.user, false);
|
|
|
|
// Hide list initially for fade-in
|
|
const list = this.container.querySelector('.comments-list');
|
|
if (list) {
|
|
list.style.opacity = '0';
|
|
list.style.transition = 'opacity 0.5s ease';
|
|
// Add a spinner or just empty? User said "not see the loading comments"
|
|
// but "layout present". Empty list is fine.
|
|
}
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
if (data.require_login) {
|
|
this.container.innerHTML = '';
|
|
this.container.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
this.isAdmin = data.is_admin || false;
|
|
this.isLocked = data.is_locked || false;
|
|
|
|
// Render real data
|
|
this.render(data.comments, data.user_id, data.is_subscribed);
|
|
|
|
// Fade in the NEW list
|
|
const list = this.container.querySelector('.comments-list');
|
|
if (list) {
|
|
list.style.opacity = '0'; // Start invisible
|
|
list.style.transition = 'opacity 0.5s ease';
|
|
|
|
// Trigger reflow
|
|
requestAnimationFrame(() => {
|
|
list.style.opacity = '1';
|
|
});
|
|
}
|
|
|
|
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 && root !== c) {
|
|
// 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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
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">> ${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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
|
|
|
|
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.
|