From f64de4d1de290a81199c30865ebdfa6240d4aa80 Mon Sep 17 00:00:00 2001 From: x Date: Sun, 25 Jan 2026 13:10:08 +0100 Subject: [PATCH] feat: Implement pinned comments, locked comment threads, and a two-level reply structure with @mentions. --- migration_add_pinned.sql | 10 ++++ public/s/css/f0ck.css | 49 ++++++++++++++++ public/s/js/comments.js | 108 ++++++++++++++++++++++++++++++++---- src/inc/routes/comments.mjs | 62 ++++++++++++++++++++- 4 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 migration_add_pinned.sql diff --git a/migration_add_pinned.sql b/migration_add_pinned.sql new file mode 100644 index 0000000..6706d1b --- /dev/null +++ b/migration_add_pinned.sql @@ -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 -U -d -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; diff --git a/public/s/css/f0ck.css b/public/s/css/f0ck.css index 1b1dab0..0e3c6bc 100644 --- a/public/s/css/f0ck.css +++ b/public/s/css/f0ck.css @@ -4086,4 +4086,53 @@ input#s_avatar { .cancel-edit-btn { background: #666; 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; } \ No newline at end of file diff --git a/public/s/js/comments.js b/public/s/js/comments.js index 16f294e..c54e93b 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -55,6 +55,7 @@ class CommentSystem { if (data.success) { this.isAdmin = data.is_admin || false; + this.isLocked = data.is_locked || false; this.render(data.comments, data.user_id, data.is_subscribed); // Priority: Explicit ID > Hash @@ -91,29 +92,75 @@ class CommentSystem { } render(comments, currentUserId, isSubscribed) { - // Build tree + // Build two-level tree: top-level comments + all replies at one level const map = new Map(); const roots = []; comments.forEach(c => { - c.children = []; + 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 && map.has(c.parent_id)) { - map.get(c.parent_id).children.push(c); - } else { + 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) { + // 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 = ''; + } + let html = `
-

Comments (${comments.length})

+

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

${currentUserId ? `` : ''} + ${lockBtn}
- ${currentUserId ? this.renderInput() : ''} + ${inputSection}
${roots.map(c => this.renderComment(c, currentUserId)).join('')}
@@ -167,37 +215,58 @@ class CommentSystem { } } - renderComment(comment, currentUserId) { + renderComment(comment, currentUserId, isReply = false) { const isDeleted = comment.is_deleted; - const content = isDeleted ? '[deleted]' : 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 = `@${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 ? `` : ''} + ${!isDeleted && currentUserId ? `` : ''} ${adminButtons}
${content}
- ${comment.children.length > 0 ? `
${comment.children.map(c => this.renderComment(c, currentUserId)).join('')}
` : ''}
+ ${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 this.container.querySelectorAll('.admin-edit-btn').forEach(btn => { btn.addEventListener('click', (e) => { @@ -309,6 +389,10 @@ class CommentSystem { 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; diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index d278eeb..41fe03d 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -14,13 +14,14 @@ export default (router, tpl) => { const comments = await db` SELECT 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, (SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count FROM comments c JOIN "user" u ON c.user_id = u.id LEFT JOIN user_options uo ON uo.user_id = u.id 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; @@ -29,6 +30,10 @@ export default (router, tpl) => { 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 return res.reply({ headers: { 'Content-Type': 'application/json' }, @@ -36,6 +41,7 @@ export default (router, tpl) => { success: true, comments, is_subscribed, + is_locked, user_id: req.session ? req.session.user : null, is_admin: req.session ? req.session.admin : false }) @@ -68,6 +74,14 @@ export default (router, tpl) => { } 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` INSERT INTO comments ${db({ item_id, @@ -209,6 +223,52 @@ export default (router, tpl) => { return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); } }); + // Toggle pin comment (admin only) + router.post(/\/api\/comments\/(?\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\/(?\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; };