feat: Implement comments, notifications, and custom emojis with new API routes, UI components, and database migrations.
This commit is contained in:
431
public/s/js/comments.js
Normal file
431
public/s/js/comments.js
Normal file
@@ -0,0 +1,431 @@
|
||||
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.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, "&")
|
||||
.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) {
|
||||
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();
|
||||
|
||||
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>` : ''}
|
||||
</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, "&")
|
||||
.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'));
|
||||
});
|
||||
});
|
||||
|
||||
// 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, "&")
|
||||
.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.
|
||||
Reference in New Issue
Block a user