feat: Implement pinned comments, locked comment threads, and a two-level reply structure with @mentions.

This commit is contained in:
x
2026-01-25 13:10:08 +01:00
parent 2c0f4f3397
commit f64de4d1de
4 changed files with 216 additions and 13 deletions

10
migration_add_pinned.sql Normal file
View File

@@ -0,0 +1,10 @@
-- Migration: Add is_pinned column to comments table
-- Migration: Add is_comments_locked column to items table
-- Run with: psql -h <host> -U <user> -d <database> -f migration_add_pinned.sql
-- Pinned comments
ALTER TABLE comments ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_comments_is_pinned ON comments(is_pinned) WHERE is_pinned = TRUE;
-- Locked threads (prevents new comments on an item)
ALTER TABLE items ADD COLUMN IF NOT EXISTS is_comments_locked BOOLEAN DEFAULT FALSE;

View File

@@ -4087,3 +4087,52 @@ input#s_avatar {
background: #666; background: #666;
color: white; color: white;
} }
/* Pinned comments */
.comment.pinned {
background: rgba(var(--accent-rgb, 153, 255, 0), 0.1);
border-left: 3px solid var(--accent);
}
.pinned-badge {
background: var(--accent);
color: var(--black);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.75em;
font-weight: bold;
margin-right: 8px;
}
.admin-pin-btn {
background: none;
border: none;
cursor: pointer;
font-size: 0.9em;
padding: 0 4px;
margin-left: 5px;
opacity: 0.6;
transition: opacity 0.2s;
}
.admin-pin-btn:hover {
opacity: 1;
}
/* Two-level comment replies */
.comment-replies {
margin-left: 40px;
border-left: 2px solid var(--gray);
padding-left: 15px;
margin-top: 10px;
}
.comment.reply {
background: rgba(0, 0, 0, 0.1);
}
.reply-mention {
color: var(--accent);
font-weight: bold;
margin-right: 4px;
}

View File

@@ -55,6 +55,7 @@ class CommentSystem {
if (data.success) { if (data.success) {
this.isAdmin = data.is_admin || false; this.isAdmin = data.is_admin || false;
this.isLocked = data.is_locked || false;
this.render(data.comments, data.user_id, data.is_subscribed); this.render(data.comments, data.user_id, data.is_subscribed);
// Priority: Explicit ID > Hash // Priority: Explicit ID > Hash
@@ -91,29 +92,75 @@ class CommentSystem {
} }
render(comments, currentUserId, isSubscribed) { render(comments, currentUserId, isSubscribed) {
// Build tree // Build two-level tree: top-level comments + all replies at one level
const map = new Map(); const map = new Map();
const roots = []; const roots = [];
comments.forEach(c => { comments.forEach(c => {
c.children = []; c.replies = [];
c.replyTo = null; // Username being replied to (for @mentions)
map.set(c.id, c); 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 => { comments.forEach(c => {
if (c.parent_id && map.has(c.parent_id)) { if (!c.parent_id) {
map.get(c.parent_id).children.push(c); // Top-level comment
} else {
roots.push(c); 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 subText = isSubscribed ? 'Subscribed' : 'Subscribe';
const subClass = isSubscribed ? 'active' : ''; 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 = ` let html = `
<div class="comments-header"> <div class="comments-header">
<h3>Comments (${comments.length})</h3> <h3>Comments (${comments.length}) ${this.isLocked ? '🔒' : ''}</h3>
<div class="comments-controls"> <div class="comments-controls">
<select id="comment-sort"> <select id="comment-sort">
<option value="old" ${this.sort === 'old' ? 'selected' : ''}>Oldest</option> <option value="old" ${this.sort === 'old' ? 'selected' : ''}>Oldest</option>
@@ -121,9 +168,10 @@ class CommentSystem {
</select> </select>
${currentUserId ? `<button id="subscribe-btn" class="${subClass}">${subText}</button>` : ''} ${currentUserId ? `<button id="subscribe-btn" class="${subClass}">${subText}</button>` : ''}
<button id="refresh-comments">Refresh</button> <button id="refresh-comments">Refresh</button>
${lockBtn}
</div> </div>
</div> </div>
${currentUserId ? this.renderInput() : '<div class="login-placeholder"><a href="/login">Login</a> to comment</div>'} ${inputSection}
<div class="comments-list"> <div class="comments-list">
${roots.map(c => this.renderComment(c, currentUserId)).join('')} ${roots.map(c => this.renderComment(c, currentUserId)).join('')}
</div> </div>
@@ -167,37 +215,58 @@ class CommentSystem {
} }
} }
renderComment(comment, currentUserId) { renderComment(comment, currentUserId, isReply = false) {
const isDeleted = comment.is_deleted; const isDeleted = comment.is_deleted;
const content = isDeleted ? '<span class="deleted-msg">[deleted]</span>' : this.renderCommentContent(comment.content); 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(); const date = new Date(comment.created_at).toLocaleString();
// Admin buttons // Admin buttons
let adminButtons = ''; let adminButtons = '';
if (this.isAdmin && !isDeleted) { if (this.isAdmin && !isDeleted) {
const pinIcon = isPinned ? '📌' : '📍';
const pinTitle = isPinned ? 'Unpin' : 'Pin';
adminButtons = ` 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-edit-btn" data-id="${comment.id}" data-content="${this.escapeHtml(comment.content)}">✏️</button>
<button class="admin-delete-btn" data-id="${comment.id}">🗑️</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 ` return `
<div class="comment ${isDeleted ? 'deleted' : ''}" id="c${comment.id}"> <div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}">
<div class="comment-avatar"> <div class="comment-avatar">
<img src="${comment.avatar ? `/t/${comment.avatar}.webp` : '/s/img/default.png'}" alt="av"> <img src="${comment.avatar ? `/t/${comment.avatar}.webp` : '/s/img/default.png'}" alt="av">
</div> </div>
<div class="comment-body"> <div class="comment-body">
<div class="comment-meta"> <div class="comment-meta">
${pinnedBadge}
<span class="comment-author">${comment.username || 'System'}</span> <span class="comment-author">${comment.username || 'System'}</span>
<span class="comment-time">${date}</span> <span class="comment-time">${date}</span>
<a href="#c${comment.id}" class="comment-permalink">#${comment.id}</a> <a href="#c${comment.id}" class="comment-permalink">#${comment.id}</a>
${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}">Reply</button>` : ''} ${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}">Reply</button>` : ''}
${adminButtons} ${adminButtons}
</div> </div>
<div class="comment-content">${content}</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>
</div> </div>
${repliesHtml}
`; `;
} }
@@ -261,6 +330,17 @@ class CommentSystem {
}); });
}); });
// 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 // Admin Edit
this.container.querySelectorAll('.admin-edit-btn').forEach(btn => { this.container.querySelectorAll('.admin-edit-btn').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
@@ -309,6 +389,10 @@ class CommentSystem {
this.container.querySelectorAll('.reply-btn').forEach(btn => { this.container.querySelectorAll('.reply-btn').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
const id = e.target.dataset.id; 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'); const body = e.target.closest('.comment-body');
// Check if input already exists // Check if input already exists
if (body.querySelector('.reply-input')) return; if (body.querySelector('.reply-input')) return;

View File

@@ -14,13 +14,14 @@ export default (router, tpl) => {
const comments = await db` const comments = await db`
SELECT SELECT
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted, c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
COALESCE(c.is_pinned, false) as is_pinned,
u.user as username, u.id as user_id, uo.avatar, u.user as username, u.id as user_id, uo.avatar,
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count (SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
FROM comments c FROM comments c
JOIN "user" u ON c.user_id = u.id JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE c.item_id = ${itemId} WHERE c.item_id = ${itemId}
ORDER BY c.created_at ${db.unsafe(sort === 'new' ? 'DESC' : 'ASC')} ORDER BY COALESCE(c.is_pinned, false) DESC, c.created_at ${db.unsafe(sort === 'new' ? 'DESC' : 'ASC')}
`; `;
let is_subscribed = false; let is_subscribed = false;
@@ -29,6 +30,10 @@ export default (router, tpl) => {
if (sub.length > 0) is_subscribed = true; if (sub.length > 0) is_subscribed = true;
} }
// Check if thread is locked
const itemInfo = await db`SELECT COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${itemId}`;
const is_locked = itemInfo.length > 0 ? itemInfo[0].is_locked : false;
// Transform for frontend if needed, or send as is // Transform for frontend if needed, or send as is
return res.reply({ return res.reply({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -36,6 +41,7 @@ export default (router, tpl) => {
success: true, success: true,
comments, comments,
is_subscribed, is_subscribed,
is_locked,
user_id: req.session ? req.session.user : null, user_id: req.session ? req.session.user : null,
is_admin: req.session ? req.session.admin : false is_admin: req.session ? req.session.admin : false
}) })
@@ -68,6 +74,14 @@ export default (router, tpl) => {
} }
try { try {
// Check if thread is locked (admins can still post)
if (!req.session.admin) {
const lockCheck = await db`SELECT COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${item_id}`;
if (lockCheck.length > 0 && lockCheck[0].is_locked) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "This thread is locked" }) });
}
}
const newComment = await db` const newComment = await db`
INSERT INTO comments ${db({ INSERT INTO comments ${db({
item_id, item_id,
@@ -209,6 +223,52 @@ export default (router, tpl) => {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
} }
}); });
// Toggle pin comment (admin only)
router.post(/\/api\/comments\/(?<id>\d+)\/pin/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!req.session.admin) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Admin only" }) });
const commentId = req.params.id;
try {
const comment = await db`SELECT id, COALESCE(is_pinned, false) as is_pinned FROM comments WHERE id = ${commentId}`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
const newPinned = !comment[0].is_pinned;
await db`UPDATE comments SET is_pinned = ${newPinned} WHERE id = ${commentId}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, is_pinned: newPinned })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Toggle lock thread (admin only)
router.post(/\/api\/comments\/(?<itemid>\d+)\/lock/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!req.session.admin) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Admin only" }) });
const itemId = req.params.itemid;
try {
const item = await db`SELECT id, COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${itemId}`;
if (!item.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
const newLocked = !item[0].is_locked;
await db`UPDATE items SET is_comments_locked = ${newLocked} WHERE id = ${itemId}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, is_locked: newLocked })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router; return router;
}; };