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.isAdmin = this.container ? this.container.dataset.isAdmin === 'true' : false; this.isLocked = this.container ? this.container.dataset.isLocked === 'true' : false; this.sort = 'new'; // Restore visibility state if (this.container) { const isHidden = localStorage.getItem('comments_hidden') === 'true'; if (isHidden) { this.container.classList.add('faded-out'); this.container.style.display = 'none'; } } 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); // Preload images to prevent NS Binding Aborted errors this.preloadEmojiImages(); } else { this.customEmojis = {}; } } catch (e) { console.error("Failed to load emojis", e); this.customEmojis = {}; } } preloadEmojiImages() { // Preload all emoji images into browser cache if (!this.customEmojis) return; Object.values(this.customEmojis).forEach(url => { const img = new Image(); img.src = url; // No need to append to DOM, just loading into cache }); } // ... renderEmoji(match, name) { // console.log('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list'); if (this.customEmojis && this.customEmojis[name]) { return `${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; } // Check for server-side preloaded comments // Check for server-side preloaded comments (Script Tag Method) const dataEl = document.getElementById('initial-comments'); if (dataEl) { try { // Decode Base64 for safe template transfer const raw = dataEl.textContent.trim(); const json = atob(raw); const comments = JSON.parse(json); const subEl = document.getElementById('initial-subscription'); // Handle boolean text content const isSubscribed = subEl && (subEl.textContent.trim() === 'true'); // Consume dataEl.remove(); if (subEl) subEl.remove(); this.render(comments, this.user, isSubscribed); 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); } return; } catch (e) { console.error("SSR comments parse error", e); } } // Render skeleton (Result: Layout visible immediately) if (!scrollToId) { this.render([], this.user, false); } 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); 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 = `
Failed to load comments: ${data.message}
`; } } catch (e) { console.error(e); this.container.innerHTML = `
Error loading comments: ${e.message}
`; } } // ... 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 ? `` : ''; const lockNotice = this.isLocked ? '
🔒 This thread is locked. New comments are disabled.
' : ''; // Determine what to show for input let inputSection = ''; if (this.isLocked && !this.isAdmin) { inputSection = '
🔒 Comments are disabled on this thread.
'; } else if (currentUserId) { inputSection = this.renderInput(); } else { inputSection = '
Login to comment
'; } let html = `

Comments (${comments.length}) ${this.isLocked ? '🔒' : ''}

${currentUserId ? `` : ''} ${lockBtn}
${inputSection}
${roots.map(c => this.renderComment(c, currentUserId)).join('')}
`; 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(/|<\/p>|\n/g, ''); return `> ${cleanQuote}
`; }; 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 = `@${comment.replyTo} `; } const content = isDeleted ? '[deleted]' : 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 = ` `; } const pinnedBadge = isPinned ? '📌 Pinned' : ''; 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 = `
${comment.replies.map(r => this.renderComment(r, currentUserId, true)).join('')}
`; } return `
av
${pinnedBadge} ${comment.username || 'System'} ${date} #${comment.id} ${!isDeleted && currentUserId ? `` : ''} ${adminButtons}
${content}
${repliesHtml} `; } escapeHtml(unsafe) { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } renderInput(parentId = null) { return `
${parentId ? '' : ''}
`; } 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 = `
`; 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'; } }); } // 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); } }); // Shortcut 'c' to toggle comments document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return; const tag = e.target.tagName.toLowerCase(); if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return; if (e.key.toLowerCase() === 'c') { if (!this.user) return; this.toggleComments(); } }); } toggleComments() { if (!this.container) return; // Check if currently hidden (or fading out) const isHidden = this.container.classList.contains('faded-out') || this.container.style.display === 'none'; if (isHidden) { // SHOW this.container.style.display = 'block'; localStorage.setItem('comments_hidden', 'false'); // Force reflow to enable transition void this.container.offsetWidth; this.container.classList.remove('faded-out'); } else { // HIDE localStorage.setItem('comments_hidden', 'true'); this.container.classList.add('faded-out'); // Wait for transition, then set display none setTimeout(() => { if (this.container.classList.contains('faded-out')) { this.container.style.display = 'none'; } }, 300); } } escapeHtml(unsafe) { return unsafe .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); // Create picker once and cache it let picker = null; let closeHandler = null; trigger.addEventListener('click', (e) => { e.preventDefault(); // If picker already exists, toggle visibility if (picker) { const isVisible = picker.style.display !== 'none'; if (isVisible) { picker.style.display = 'none'; if (closeHandler) { document.removeEventListener('click', closeHandler); closeHandler = null; } } else { picker.style.display = 'block'; closeHandler = (ev) => { if (!picker.contains(ev.target) && ev.target !== trigger) { picker.style.display = 'none'; document.removeEventListener('click', closeHandler); closeHandler = null; } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); } return; } // Create picker only once 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.loading = 'lazy'; // Use native lazy loading // Add error handling for failed loads img.onerror = () => { console.warn(`Failed to load emoji: ${name}`); img.style.display = 'none'; }; img.onclick = (ev) => { ev.stopPropagation(); textarea.value += ` :${name}: `; textarea.focus(); }; picker.appendChild(img); }); } else { picker.innerHTML = '
No emojis found
'; } trigger.after(picker); // Set up close handler closeHandler = (ev) => { if (!picker.contains(ev.target) && ev.target !== trigger) { picker.style.display = 'none'; document.removeEventListener('click', closeHandler); closeHandler = null; } }; setTimeout(() => document.addEventListener('click', closeHandler), 0); }); } } } // 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.