feat: Implement pinned comments, locked comment threads, and a two-level reply structure with @mentions.
This commit is contained in:
10
migration_add_pinned.sql
Normal file
10
migration_add_pinned.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user